This is page 2 of 3. Use http://codebase.md/nulab/backlog-mcp-server?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .clineigonre ├── .clinerules │ └── commit-conventional-format.md ├── .env.example ├── .github │ └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .release-it.json ├── .tool-versions ├── CHANGELOG.md ├── Dockerfile ├── eslint.config.js ├── jest.config.js ├── LICENSE ├── memory-bank │ ├── activeContext.md │ ├── productContext.md │ ├── progress.md │ ├── projectbrief.md │ ├── systemPatterns.md │ ├── techContext.md │ └── URLlist.md ├── package-lock.json ├── package.json ├── README.ja.md ├── README.md ├── scripts │ └── replace-version.js ├── src │ ├── backlog │ │ ├── backlogErrorHandler.ts │ │ ├── customFields.test.ts │ │ ├── customFields.ts │ │ └── parseBacklogAPIError.ts │ ├── createTranslationHelper.test.ts │ ├── createTranslationHelper.ts │ ├── handlers │ │ ├── builders │ │ │ ├── composeToolHandler.test.ts │ │ │ └── composeToolHandler.ts │ │ └── transformers │ │ ├── wrapWithErrorHandling.test.ts │ │ ├── wrapWithErrorHandling.ts │ │ ├── wrapWithFieldPicking.test.ts │ │ ├── wrapWithFieldPicking.ts │ │ ├── wrapWithTokenLimit.test.ts │ │ ├── wrapWithTokenLimit.ts │ │ ├── wrapWithToolResult.test.ts │ │ └── wrapWithToolResult.ts │ ├── index.ts │ ├── registerTools.test.ts │ ├── registerTools.ts │ ├── tools │ │ ├── addIssue.test.ts │ │ ├── addIssue.ts │ │ ├── addIssueComment.test.ts │ │ ├── addIssueComment.ts │ │ ├── addProject.test.ts │ │ ├── addProject.ts │ │ ├── addPullRequest.test.ts │ │ ├── addPullRequest.ts │ │ ├── addPullRequestComment.test.ts │ │ ├── addPullRequestComment.ts │ │ ├── addVersionMilestone.test.ts │ │ ├── addVersionMilestone.ts │ │ ├── addWiki.test.ts │ │ ├── addWiki.ts │ │ ├── countIssues.test.ts │ │ ├── countIssues.ts │ │ ├── deleteIssue.test.ts │ │ ├── deleteIssue.ts │ │ ├── deleteProject.test.ts │ │ ├── deleteProject.ts │ │ ├── deleteVersion.test.ts │ │ ├── deleteVersion.ts │ │ ├── dynamicTools │ │ │ ├── toolsets.test.ts │ │ │ └── toolsets.ts │ │ ├── getCategories.test.ts │ │ ├── getCategories.ts │ │ ├── getCustomFields.test.ts │ │ ├── getCustomFields.ts │ │ ├── getDocument.test.ts │ │ ├── getDocument.ts │ │ ├── getDocuments.test.ts │ │ ├── getDocuments.ts │ │ ├── getDocumentTree.test.ts │ │ ├── getDocumentTree.ts │ │ ├── getGitRepositories.test.ts │ │ ├── getGitRepositories.ts │ │ ├── getGitRepository.test.ts │ │ ├── getGitRepository.ts │ │ ├── getIssue.test.ts │ │ ├── getIssue.ts │ │ ├── getIssueComments.test.ts │ │ ├── getIssueComments.ts │ │ ├── getIssues.test.ts │ │ ├── getIssues.ts │ │ ├── getIssueTypes.test.ts │ │ ├── getIssueTypes.ts │ │ ├── getMyself.test.ts │ │ ├── getMyself.ts │ │ ├── getNotifications.test.ts │ │ ├── getNotifications.ts │ │ ├── getNotificationsCount.test.ts │ │ ├── getNotificationsCount.ts │ │ ├── getPriorities.test.ts │ │ ├── getPriorities.ts │ │ ├── getProject.test.ts │ │ ├── getProject.ts │ │ ├── getProjectList.test.ts │ │ ├── getProjectList.ts │ │ ├── getPullRequest.test.ts │ │ ├── getPullRequest.ts │ │ ├── getPullRequestComments.test.ts │ │ ├── getPullRequestComments.ts │ │ ├── getPullRequests.test.ts │ │ ├── getPullRequests.ts │ │ ├── getPullRequestsCount.test.ts │ │ ├── getPullRequestsCount.ts │ │ ├── getResolutions.test.ts │ │ ├── getResolutions.ts │ │ ├── getSpace.test.ts │ │ ├── getSpace.ts │ │ ├── getUsers.test.ts │ │ ├── getUsers.ts │ │ ├── getVersionMilestoneList.test.ts │ │ ├── getVersionMilestoneList.ts │ │ ├── getWatchingListCount.test.ts │ │ ├── getWatchingListCount.ts │ │ ├── getWatchingListItems.test.ts │ │ ├── getWatchingListItems.ts │ │ ├── getWiki.test.ts │ │ ├── getWiki.ts │ │ ├── getWikiPages.test.ts │ │ ├── getWikiPages.ts │ │ ├── getWikisCount.test.ts │ │ ├── getWikisCount.ts │ │ ├── markNotificationAsRead.test.ts │ │ ├── markNotificationAsRead.ts │ │ ├── resetUnreadNotificationCount.test.ts │ │ ├── resetUnreadNotificationCount.ts │ │ ├── tools.ts │ │ ├── updateIssue.test.ts │ │ ├── updateIssue.ts │ │ ├── updateProject.test.ts │ │ ├── updateProject.ts │ │ ├── updatePullRequest.test.ts │ │ ├── updatePullRequest.ts │ │ ├── updatePullRequestComment.test.ts │ │ ├── updatePullRequestComment.ts │ │ ├── updateVersionMilestone.test.ts │ │ └── updateVersionMilestone.ts │ ├── types │ │ ├── mcp.ts │ │ ├── result.ts │ │ ├── tool.ts │ │ ├── toolsets.ts │ │ └── zod │ │ └── backlogOutputDefinition.ts │ ├── utils │ │ ├── generateFieldsDescription.test.ts │ │ ├── generateFieldsDescription.ts │ │ ├── logger.ts │ │ ├── resolveIdOrKey.test.ts │ │ ├── resolveIdOrKey.ts │ │ ├── runToolSafely.test.ts │ │ ├── runToolSafely.ts │ │ ├── tokenCounter.test.ts │ │ ├── tokenCounter.ts │ │ ├── toolRegistrar.test.ts │ │ ├── toolRegistrar.ts │ │ ├── toolsetUtils.test.ts │ │ ├── toolsetUtils.ts │ │ ├── wrapServerWithToolRegistry.test.ts │ │ └── wrapServerWithToolRegistry.ts │ └── version.template.ts ├── translationConfig │ └── .backlog-mcp-serverrc.json.example └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /src/tools/addVersionMilestone.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { Backlog } from 'backlog-js'; 3 | import { buildToolSchema, ToolDefinition } from '../types/tool.js'; 4 | import { TranslationHelper } from '../createTranslationHelper.js'; 5 | import { VersionSchema } from '../types/zod/backlogOutputDefinition.js'; 6 | import { resolveIdOrKey } from '../utils/resolveIdOrKey.js'; 7 | 8 | const addVersionMilestoneSchema = buildToolSchema((t) => ({ 9 | projectId: z 10 | .number() 11 | .optional() 12 | .describe(t('TOOL_ADD_VERSION_MILESTONE_PROJECT_ID', 'Project ID')), 13 | projectKey: z 14 | .string() 15 | .optional() 16 | .describe(t('TOOL_ADD_VERSION_MILESTONE_PROJECT_KEY', 'Project key')), 17 | name: z 18 | .string() 19 | .describe(t('TOOL_ADD_VERSION_MILESTONE_NAME', 'Version name')), 20 | description: z 21 | .string() 22 | .optional() 23 | .describe( 24 | t('TOOL_ADD_VERSION_MILESTONE_DESCRIPTION', 'Version description') 25 | ), 26 | startDate: z 27 | .string() 28 | .optional() 29 | .describe( 30 | t('TOOL_ADD_VERSION_MILESTONE_START_DATE', 'Start date of the version') 31 | ), 32 | releaseDueDate: z 33 | .string() 34 | .optional() 35 | .describe( 36 | t( 37 | 'TOOL_ADD_VERSION_MILESTONE_RELEASE_DUE_DATE', 38 | 'Release due date of the version' 39 | ) 40 | ), 41 | })); 42 | 43 | export const addVersionMilestoneTool = ( 44 | backlog: Backlog, 45 | { t }: TranslationHelper 46 | ): ToolDefinition< 47 | ReturnType<typeof addVersionMilestoneSchema>, 48 | (typeof VersionSchema)['shape'] 49 | > => { 50 | return { 51 | name: 'add_version_milestone', 52 | description: t( 53 | 'TOOL_ADD_VERSION_MILESTONE_DESCRIPTION', 54 | 'Creates a new version milestone' 55 | ), 56 | schema: z.object(addVersionMilestoneSchema(t)), 57 | outputSchema: VersionSchema, 58 | importantFields: [ 59 | 'id', 60 | 'name', 61 | 'description', 62 | 'startDate', 63 | 'releaseDueDate', 64 | ], 65 | handler: async ({ projectId, projectKey, ...params }) => { 66 | const result = resolveIdOrKey( 67 | 'project', 68 | { id: projectId, key: projectKey }, 69 | t 70 | ); 71 | if (!result.ok) { 72 | throw result.error; 73 | } 74 | return backlog.postVersions(result.value, params); 75 | }, 76 | }; 77 | }; 78 | ``` -------------------------------------------------------------------------------- /src/registerTools.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { backlogErrorHandler } from './backlog/backlogErrorHandler.js'; 2 | import { composeToolHandler } from './handlers/builders/composeToolHandler.js'; 3 | import { MCPOptions } from './types/mcp.js'; 4 | import { DynamicToolDefinition, ToolDefinition } from './types/tool.js'; 5 | import { DynamicToolsetGroup, ToolsetGroup } from './types/toolsets.js'; 6 | import { BacklogMCPServer } from './utils/wrapServerWithToolRegistry.js'; 7 | 8 | type ToolsetSource = ToolsetGroup | DynamicToolsetGroup; 9 | 10 | type RegisterOptions = { 11 | server: BacklogMCPServer; 12 | toolsetGroup: ToolsetSource; 13 | prefix: string; 14 | onlyEnabled?: boolean; 15 | handlerStrategy: ( 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | tool: ToolDefinition<any, any> | DynamicToolDefinition<any> 18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 | ) => (...args: any[]) => any; 20 | }; 21 | 22 | export function registerTools( 23 | server: BacklogMCPServer, 24 | toolsetGroup: ToolsetGroup, 25 | options: MCPOptions 26 | ) { 27 | const { useFields, maxTokens, prefix } = options; 28 | 29 | registerToolsets({ 30 | server, 31 | toolsetGroup, 32 | prefix, 33 | handlerStrategy: (tool) => 34 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 35 | composeToolHandler(tool as ToolDefinition<any, any>, { 36 | useFields, 37 | errorHandler: backlogErrorHandler, 38 | maxTokens, 39 | }), 40 | }); 41 | } 42 | 43 | export function registerDyamicTools( 44 | server: BacklogMCPServer, 45 | dynamicToolsetGroup: DynamicToolsetGroup, 46 | prefix: string 47 | ) { 48 | registerToolsets({ 49 | server, 50 | toolsetGroup: dynamicToolsetGroup, 51 | prefix, 52 | handlerStrategy: (tool) => tool.handler, 53 | }); 54 | } 55 | 56 | function registerToolsets({ 57 | server, 58 | toolsetGroup, 59 | prefix, 60 | handlerStrategy, 61 | }: RegisterOptions) { 62 | for (const toolset of toolsetGroup.toolsets) { 63 | if (!toolset.enabled) { 64 | continue; 65 | } 66 | 67 | for (const tool of toolset.tools) { 68 | const toolNameWithPrefix = `${prefix}${tool.name}`; 69 | const handler = handlerStrategy(tool); 70 | 71 | server.registerOnce( 72 | toolNameWithPrefix, 73 | tool.description, 74 | tool.schema.shape, 75 | handler 76 | ); 77 | } 78 | } 79 | } 80 | ``` -------------------------------------------------------------------------------- /src/tools/getPullRequest.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { Backlog } from 'backlog-js'; 3 | import { buildToolSchema, ToolDefinition } from '../types/tool.js'; 4 | import { TranslationHelper } from '../createTranslationHelper.js'; 5 | import { PullRequestSchema } from '../types/zod/backlogOutputDefinition.js'; 6 | import { resolveIdOrKey, resolveIdOrName } from '../utils/resolveIdOrKey.js'; 7 | 8 | const getPullRequestSchema = buildToolSchema((t) => ({ 9 | projectId: z 10 | .number() 11 | .optional() 12 | .describe( 13 | t( 14 | 'TOOL_GET_PULL_REQUEST_PROJECT_ID', 15 | 'The numeric ID of the project (e.g., 12345)' 16 | ) 17 | ), 18 | projectKey: z 19 | .string() 20 | .optional() 21 | .describe( 22 | t( 23 | 'TOOL_GET_PULL_REQUEST_PROJECT_KEY', 24 | "The key of the project (e.g., 'PROJECT')" 25 | ) 26 | ), 27 | repoId: z 28 | .number() 29 | .optional() 30 | .describe(t('TOOL_GET_PULL_REQUEST_REPO_ID', 'Repository ID')), 31 | repoName: z 32 | .string() 33 | .optional() 34 | .describe(t('TOOL_GET_PULL_REQUEST_REPO_NAME', 'Repository name')), 35 | number: z 36 | .number() 37 | .describe(t('TOOL_GET_PULL_REQUEST_NUMBER', 'Pull request number')), 38 | })); 39 | 40 | export const getPullRequestTool = ( 41 | backlog: Backlog, 42 | { t }: TranslationHelper 43 | ): ToolDefinition< 44 | ReturnType<typeof getPullRequestSchema>, 45 | (typeof PullRequestSchema)['shape'] 46 | > => { 47 | return { 48 | name: 'get_pull_request', 49 | description: t( 50 | 'TOOL_GET_PULL_REQUEST_DESCRIPTION', 51 | 'Returns information about a specific pull request' 52 | ), 53 | schema: z.object(getPullRequestSchema(t)), 54 | outputSchema: PullRequestSchema, 55 | handler: async ({ projectId, projectKey, repoId, repoName, number }) => { 56 | const result = resolveIdOrKey( 57 | 'project', 58 | { id: projectId, key: projectKey }, 59 | t 60 | ); 61 | if (!result.ok) { 62 | throw result.error; 63 | } 64 | const repoRes = resolveIdOrName( 65 | 'repository', 66 | { id: repoId, name: repoName }, 67 | t 68 | ); 69 | if (!repoRes.ok) { 70 | throw repoRes.error; 71 | } 72 | return backlog.getPullRequest( 73 | result.value, 74 | String(repoRes.value), 75 | number 76 | ); 77 | }, 78 | }; 79 | }; 80 | ``` -------------------------------------------------------------------------------- /src/tools/addProject.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { Backlog } from 'backlog-js'; 3 | import { buildToolSchema, ToolDefinition } from '../types/tool.js'; 4 | import { TranslationHelper } from '../createTranslationHelper.js'; 5 | import { ProjectSchema } from '../types/zod/backlogOutputDefinition.js'; 6 | 7 | const addProjectSchema = buildToolSchema((t) => ({ 8 | name: z.string().describe(t('TOOL_ADD_PROJECT_NAME', 'Project name')), 9 | key: z.string().describe(t('TOOL_ADD_PROJECT_KEY', 'Project key')), 10 | chartEnabled: z 11 | .boolean() 12 | .optional() 13 | .describe( 14 | t( 15 | 'TOOL_ADD_PROJECT_CHART_ENABLED', 16 | 'Whether to enable chart (default: false)' 17 | ) 18 | ), 19 | subtaskingEnabled: z 20 | .boolean() 21 | .optional() 22 | .describe( 23 | t( 24 | 'TOOL_ADD_PROJECT_SUBTASKING_ENABLED', 25 | 'Whether to enable subtasking (default: false)' 26 | ) 27 | ), 28 | projectLeaderCanEditProjectLeader: z 29 | .boolean() 30 | .optional() 31 | .describe( 32 | t( 33 | 'TOOL_ADD_PROJECT_LEADER_CAN_EDIT', 34 | 'Whether project leaders can edit other project leaders (default: false)' 35 | ) 36 | ), 37 | textFormattingRule: z 38 | .enum(['backlog', 'markdown']) 39 | .optional() 40 | .describe( 41 | t( 42 | 'TOOL_ADD_PROJECT_TEXT_FORMATTING', 43 | "Text formatting rule (default: 'backlog')" 44 | ) 45 | ), 46 | })); 47 | 48 | export const addProjectTool = ( 49 | backlog: Backlog, 50 | { t }: TranslationHelper 51 | ): ToolDefinition< 52 | ReturnType<typeof addProjectSchema>, 53 | (typeof ProjectSchema)['shape'] 54 | > => { 55 | return { 56 | name: 'add_project', 57 | description: t('TOOL_ADD_PROJECT_DESCRIPTION', 'Creates a new project'), 58 | schema: z.object(addProjectSchema(t)), 59 | outputSchema: ProjectSchema, 60 | handler: async ({ 61 | name, 62 | key, 63 | chartEnabled, 64 | subtaskingEnabled, 65 | projectLeaderCanEditProjectLeader, 66 | textFormattingRule, 67 | }) => 68 | backlog.postProject({ 69 | name, 70 | key, 71 | chartEnabled: chartEnabled ?? false, 72 | subtaskingEnabled: subtaskingEnabled ?? false, 73 | projectLeaderCanEditProjectLeader: 74 | projectLeaderCanEditProjectLeader ?? false, 75 | textFormattingRule: textFormattingRule ?? 'backlog', 76 | }), 77 | }; 78 | }; 79 | ``` -------------------------------------------------------------------------------- /src/utils/toolRegistrar.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, expect, it, jest } from '@jest/globals'; 2 | import { MCPOptions } from '../types/mcp'; 3 | import { ToolsetGroup } from '../types/toolsets'; 4 | import { createToolRegistrar } from '../utils/toolRegistrar'; 5 | import { BacklogMCPServer } from './wrapServerWithToolRegistry'; 6 | 7 | jest.mock('../registerTools', () => ({ 8 | registerTools: jest.fn(), 9 | })); 10 | 11 | const mockSendToolListChanged = jest.fn(); 12 | 13 | const serverMock = { 14 | server: { 15 | sendToolListChanged: mockSendToolListChanged, 16 | }, 17 | tool: jest.fn(), 18 | __registeredToolNames: new Set<string>(), 19 | registerOnce: () => {}, 20 | } as unknown as BacklogMCPServer; 21 | 22 | const options: MCPOptions = { 23 | useFields: true, 24 | maxTokens: 5000, 25 | prefix: '', 26 | }; 27 | 28 | describe('createToolRegistrar', () => { 29 | it('enables a toolset and refreshes tool list', async () => { 30 | const toolsetGroup: ToolsetGroup = { 31 | toolsets: [ 32 | { 33 | name: 'issue', 34 | description: 'Issue toolset', 35 | enabled: false, 36 | tools: [], 37 | }, 38 | ], 39 | }; 40 | 41 | const registrar = createToolRegistrar(serverMock, toolsetGroup, options); 42 | const msg = await registrar.enableToolsetAndRefresh('issue'); 43 | 44 | expect(msg).toBe('Toolset issue enabled'); 45 | expect(toolsetGroup.toolsets[0].enabled).toBe(true); 46 | 47 | expect(mockSendToolListChanged).toHaveBeenCalled(); 48 | }); 49 | 50 | it('returns already enabled message if toolset is already enabled', async () => { 51 | const toolsetGroup: ToolsetGroup = { 52 | toolsets: [ 53 | { 54 | name: 'project', 55 | description: 'Project toolset', 56 | enabled: true, 57 | tools: [], 58 | }, 59 | ], 60 | }; 61 | 62 | const registrar = createToolRegistrar(serverMock, toolsetGroup, options); 63 | const msg = await registrar.enableToolsetAndRefresh('project'); 64 | 65 | expect(msg).toBe('Toolset project is already enabled'); 66 | }); 67 | 68 | it('returns not found message if toolset does not exist', async () => { 69 | const toolsetGroup: ToolsetGroup = { 70 | toolsets: [], 71 | }; 72 | 73 | const registrar = createToolRegistrar(serverMock, toolsetGroup, options); 74 | const msg = await registrar.enableToolsetAndRefresh('unknown'); 75 | 76 | expect(msg).toBe('Toolset unknown not found'); 77 | }); 78 | }); 79 | ``` -------------------------------------------------------------------------------- /src/tools/getNotifications.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getNotificationsTool } from './getNotifications.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('getNotificationsTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | getNotifications: jest.fn<() => Promise<any>>().mockResolvedValue([ 9 | { 10 | id: 1, 11 | alreadyRead: false, 12 | resourceAlreadyRead: false, 13 | reason: 1, 14 | user: { 15 | id: 1, 16 | userId: 'user1', 17 | name: 'User One', 18 | }, 19 | project: { 20 | id: 1, 21 | projectKey: 'TEST', 22 | name: 'Test Project', 23 | }, 24 | issue: { 25 | id: 1, 26 | issueKey: 'TEST-1', 27 | summary: 'Test Issue', 28 | }, 29 | comment: { 30 | id: 1, 31 | content: 'Test comment', 32 | }, 33 | created: '2023-01-01T00:00:00Z', 34 | }, 35 | { 36 | id: 2, 37 | alreadyRead: true, 38 | resourceAlreadyRead: true, 39 | reason: 2, 40 | user: { 41 | id: 2, 42 | userId: 'user2', 43 | name: 'User Two', 44 | }, 45 | project: { 46 | id: 1, 47 | projectKey: 'TEST', 48 | name: 'Test Project', 49 | }, 50 | issue: { 51 | id: 2, 52 | issueKey: 'TEST-2', 53 | summary: 'Another Issue', 54 | }, 55 | created: '2023-01-02T00:00:00Z', 56 | }, 57 | ]), 58 | }; 59 | 60 | const mockTranslationHelper = createTranslationHelper(); 61 | const tool = getNotificationsTool( 62 | mockBacklog as Backlog, 63 | mockTranslationHelper 64 | ); 65 | 66 | it('returns notifications list as formatted JSON text', async () => { 67 | const result = await tool.handler({}); 68 | 69 | if (!Array.isArray(result)) { 70 | throw new Error('Unexpected non array result'); 71 | } 72 | expect(result[0].issue?.summary).toContain('Test Issue'); 73 | expect(result[1].issue?.summary).toContain('Another Issue'); 74 | }); 75 | 76 | it('calls backlog.getNotifications with correct params', async () => { 77 | const params = { 78 | minId: 100, 79 | maxId: 200, 80 | count: 20, 81 | order: 'desc' as const, 82 | }; 83 | 84 | await tool.handler(params); 85 | 86 | expect(mockBacklog.getNotifications).toHaveBeenCalledWith(params); 87 | }); 88 | }); 89 | ``` -------------------------------------------------------------------------------- /memory-bank/productContext.md: -------------------------------------------------------------------------------- ```markdown 1 | # Product Context 2 | 3 | ## Project Purpose 4 | 5 | The Backlog MCP Server is a server that integrates Backlog's API with the [Model Context Protocol (MCP)](https://github.com/anthropics/model-context-protocol), allowing Claude AI assistant to directly access Backlog's project management features. 6 | 7 | ## Problems Solved 8 | 9 | 1. **AI and Backlog Integration** 10 | - Provides a means for large language models (LLMs) like Claude to access and manipulate Backlog data 11 | - Allows users to operate Backlog through AI assistants 12 | 13 | 2. **Project Management Efficiency** 14 | - Enables Backlog operations through natural language, reducing UI operations 15 | - Allows complex queries and batch operations to be delegated to AI 16 | 17 | 3. **Simplified Information Access** 18 | - Provides a unified access method to project, issue, Wiki, and Git information 19 | - Makes it easier to retrieve information across multiple Backlog features 20 | 21 | ## Key Use Cases 22 | 23 | 1. **Project Management** 24 | - Creating, updating, and deleting projects 25 | - Retrieving and analyzing project information 26 | 27 | 2. **Issue Management** 28 | - Creating, updating, and deleting issues 29 | - Searching and listing issues 30 | - Adding comments to issues 31 | 32 | 3. **Wiki Management** 33 | - Retrieving and searching Wiki pages 34 | - Analyzing Wiki information 35 | 36 | 4. **Git/Pull Request Management** 37 | - Retrieving repository information 38 | - Creating, updating, and commenting on pull requests 39 | - Retrieving and analyzing pull request lists 40 | 41 | 5. **Notification Management** 42 | - Retrieving and marking notifications as read 43 | - Counting and resetting notification counts 44 | 45 | 6. **Watch Management** 46 | - Retrieving lists of watched items 47 | - Counting watches 48 | 49 | ## User Experience Goals 50 | 51 | 1. **Seamless Integration** 52 | - Natural operation of Backlog from within Claude etc 53 | - Operation without being conscious of complex API details 54 | 55 | 2. **Multi-language Support** 56 | - Support for tool descriptions in multiple languages including Japanese and English 57 | - Providing a user experience tailored to the user's language environment 58 | 59 | 3. **Flexible Deployment** 60 | - Easy deployment via Docker 61 | - Customization of settings through environment variables 62 | 63 | 4. **Extensibility** 64 | - Easy adaptation to new Backlog API endpoints 65 | - Customization of functionality through custom descriptions 66 | ``` -------------------------------------------------------------------------------- /src/tools/getVersionMilestoneList.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getVersionMilestoneListTool } from './getVersionMilestoneList.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('getVersionMilestoneTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | getVersions: jest.fn<() => Promise<any>>().mockResolvedValue([ 9 | { 10 | id: 1, 11 | projectId: 1, 12 | name: 'wait for release', 13 | description: '', 14 | startDate: null, 15 | releaseDueDate: null, 16 | archived: false, 17 | displayOrder: 0, 18 | }, 19 | { 20 | id: 2, 21 | projectId: 1, 22 | name: 'v1.0.0', 23 | description: 'First release', 24 | startDate: '2025-01-01', 25 | releaseDueDate: '2025-03-01', 26 | archived: false, 27 | displayOrder: 1, 28 | }, 29 | { 30 | id: 3, 31 | projectId: 1, 32 | name: 'v1.1.0', 33 | description: 'Minor update', 34 | startDate: '2025-03-01', 35 | releaseDueDate: '2025-05-01', 36 | archived: false, 37 | displayOrder: 2, 38 | }, 39 | ]), 40 | }; 41 | 42 | const mockTranslationHelper = createTranslationHelper(); 43 | const tool = getVersionMilestoneListTool( 44 | mockBacklog as Backlog, 45 | mockTranslationHelper 46 | ); 47 | 48 | it('returns versions list as formatted JSON text', async () => { 49 | const result = await tool.handler({ projectId: 123 }); 50 | 51 | if (!Array.isArray(result)) { 52 | throw new Error('Unexpected non array result'); 53 | } 54 | 55 | expect(result).toHaveLength(3); 56 | expect(result[0].name).toContain('wait for release'); 57 | expect(result[1].name).toContain('v1.0.0'); 58 | expect(result[2].name).toContain('v1.1.0'); 59 | }); 60 | 61 | it('calls backlog.getVersions with correct params when using project key', async () => { 62 | await tool.handler({ 63 | projectKey: 'TEST_PROJECT', 64 | }); 65 | 66 | expect(mockBacklog.getVersions).toHaveBeenCalledWith('TEST_PROJECT'); 67 | }); 68 | 69 | it('calls backlog.getVersions with correct params when using project ID', async () => { 70 | await tool.handler({ 71 | projectId: 123, 72 | }); 73 | 74 | expect(mockBacklog.getVersions).toHaveBeenCalledWith(123); 75 | }); 76 | 77 | it('throws an error if neither projectId nor projectKey is provided', async () => { 78 | const params = {}; // No identifier provided 79 | 80 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 81 | }); 82 | }); 83 | ``` -------------------------------------------------------------------------------- /src/handlers/transformers/wrapWithFieldPicking.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { wrapWithFieldPicking } from './wrapWithFieldPicking'; 2 | import type { SafeResult } from '../../types/result'; 3 | import { jest, describe, it, expect } from '@jest/globals'; 4 | 5 | describe('wrapWithFieldPicking', () => { 6 | const fullData = { 7 | id: 1, 8 | name: 'Project A', 9 | config: { 10 | mode: 'advanced', 11 | enabled: true, 12 | }, 13 | extra: 'should be ignored', 14 | }; 15 | 16 | const successResult: SafeResult<typeof fullData> = { 17 | kind: 'ok', 18 | data: fullData, 19 | }; 20 | 21 | const mockFn = jest.fn(async () => successResult); 22 | 23 | it('returns full data when fields is not specified', async () => { 24 | const wrapped = wrapWithFieldPicking(mockFn); 25 | const result = await wrapped({}); 26 | 27 | expect(result).toEqual(successResult); 28 | }); 29 | 30 | it('filters top-level fields', async () => { 31 | const wrapped = wrapWithFieldPicking(mockFn); 32 | const result = await wrapped({ 33 | fields: `{ id name }`, 34 | }); 35 | 36 | expect(result).toEqual({ 37 | kind: 'ok', 38 | data: { 39 | id: 1, 40 | name: 'Project A', 41 | }, 42 | }); 43 | }); 44 | 45 | it('filters nested fields', async () => { 46 | const wrapped = wrapWithFieldPicking(mockFn); 47 | const result = await wrapped({ 48 | fields: `{ config { mode } }`, 49 | }); 50 | 51 | expect(result).toEqual({ 52 | kind: 'ok', 53 | data: { 54 | config: { 55 | mode: 'advanced', 56 | }, 57 | }, 58 | }); 59 | }); 60 | 61 | it('returns original error if result is an error', async () => { 62 | const errorResult = { kind: 'error', message: 'boom' } as const; 63 | const errorFn = jest.fn(async () => errorResult); 64 | 65 | const wrapped = wrapWithFieldPicking(errorFn); 66 | const result = await wrapped({ fields: `{ id }` }); 67 | 68 | expect(result).toBe(errorResult); 69 | }); 70 | 71 | it('ignores fields not in data', async () => { 72 | const wrapped = wrapWithFieldPicking(mockFn); 73 | const result = await wrapped({ fields: `{ id unknown }` }); 74 | 75 | expect(result).toEqual({ 76 | kind: 'ok', 77 | data: { 78 | id: 1, 79 | }, 80 | }); 81 | }); 82 | 83 | it('filters arrays of objects', async () => { 84 | const arrFn = jest.fn( 85 | async (_) => 86 | ({ 87 | kind: 'ok', 88 | data: [ 89 | { id: 1, name: 'A', unused: true }, 90 | { id: 2, name: 'B', unused: false }, 91 | ], 92 | }) as const 93 | ); 94 | 95 | const wrapped = wrapWithFieldPicking(arrFn); 96 | const result = await wrapped({ fields: `{ name }` }); 97 | 98 | expect(result).toEqual({ 99 | kind: 'ok', 100 | data: [{ name: 'A' }, { name: 'B' }], 101 | }); 102 | }); 103 | }); 104 | ``` -------------------------------------------------------------------------------- /src/tools/updateVersionMilestone.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { Backlog } from 'backlog-js'; 3 | import { buildToolSchema, ToolDefinition } from '../types/tool.js'; 4 | import { TranslationHelper } from '../createTranslationHelper.js'; 5 | import { VersionSchema } from '../types/zod/backlogOutputDefinition.js'; 6 | import { resolveIdOrKey } from '../utils/resolveIdOrKey.js'; 7 | 8 | const updateVersionMilestoneSchema = buildToolSchema((t) => ({ 9 | projectId: z 10 | .number() 11 | .optional() 12 | .describe( 13 | t( 14 | 'TOOL_UPDATE_VERSION_MILESTONE_PROJECT_ID', 15 | 'The numeric ID of the project (e.g., 12345)' 16 | ) 17 | ), 18 | projectKey: z 19 | .string() 20 | .optional() 21 | .describe( 22 | t( 23 | 'TOOL_UPDATE_VERSION_MILESTONE_PROJECT_KEY', 24 | "The key of the project (e.g., 'PROJECT')" 25 | ) 26 | ), 27 | id: z.number().describe(t('TOOL_UPDATE_VERSION_MILESTONE_ID', 'Version ID')), 28 | name: z 29 | .string() 30 | .describe(t('TOOL_UPDATE_VERSION_MILESTONE_NAME', 'Version name')), 31 | description: z 32 | .string() 33 | .optional() 34 | .describe( 35 | t('TOOL_UPDATE_VERSION_MILESTONE_DESCRIPTION', 'Version description') 36 | ), 37 | startDate: z 38 | .string() 39 | .optional() 40 | .describe(t('TOOL_UPDATE_VERSION_MILESTONE_START_DATE', 'Start date')), 41 | releaseDueDate: z 42 | .string() 43 | .optional() 44 | .describe( 45 | t('TOOL_UPDATE_VERSION_MILESTONE_RELEASE_DUE_DATE', 'Release due date') 46 | ), 47 | archived: z 48 | .boolean() 49 | .optional() 50 | .describe( 51 | t( 52 | 'TOOL_UPDATE_VERSION_MILESTONE_ARCHIVED', 53 | 'Archive status of the version' 54 | ) 55 | ), 56 | })); 57 | 58 | export const updateVersionMilestoneTool = ( 59 | backlog: Backlog, 60 | { t }: TranslationHelper 61 | ): ToolDefinition< 62 | ReturnType<typeof updateVersionMilestoneSchema>, 63 | (typeof VersionSchema)['shape'] 64 | > => { 65 | return { 66 | name: 'update_version_milestone', 67 | description: t( 68 | 'TOOL_UPDATE_VERSION_MILESTONE_DESCRIPTION', 69 | 'Updates an existing version milestone' 70 | ), 71 | schema: z.object(updateVersionMilestoneSchema(t)), 72 | outputSchema: VersionSchema, 73 | importantFields: [ 74 | 'id', 75 | 'name', 76 | 'description', 77 | 'startDate', 78 | 'releaseDueDate', 79 | 'archived', 80 | ], 81 | handler: async ({ projectId, projectKey, id, ...params }) => { 82 | const result = resolveIdOrKey( 83 | 'project', 84 | { id: projectId, key: projectKey }, 85 | t 86 | ); 87 | if (!result.ok) { 88 | throw result.error; 89 | } 90 | return backlog.patchVersions(result.value, id, params); 91 | }, 92 | }; 93 | }; 94 | ``` -------------------------------------------------------------------------------- /src/tools/updateProject.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { Backlog } from 'backlog-js'; 3 | import { buildToolSchema, ToolDefinition } from '../types/tool.js'; 4 | import { TranslationHelper } from '../createTranslationHelper.js'; 5 | import { ProjectSchema } from '../types/zod/backlogOutputDefinition.js'; 6 | import { resolveIdOrKey } from '../utils/resolveIdOrKey.js'; 7 | 8 | const updateProjectSchema = buildToolSchema((t) => ({ 9 | projectId: z 10 | .number() 11 | .optional() 12 | .describe( 13 | t( 14 | 'TOOL_UPDATE_PROJECT_PROJECT_ID', 15 | 'The numeric ID of the project (e.g., 12345)' 16 | ) 17 | ), 18 | projectKey: z 19 | .string() 20 | .optional() 21 | .describe( 22 | t( 23 | 'TOOL_UPDATE_PROJECT_PROJECT_KEY', 24 | "The key of the project (e.g., 'PROJECT')" 25 | ) 26 | ), 27 | name: z 28 | .string() 29 | .optional() 30 | .describe(t('TOOL_UPDATE_PROJECT_NAME', 'Project name')), 31 | key: z 32 | .string() 33 | .optional() 34 | .describe(t('TOOL_UPDATE_PROJECT_KEY', 'Project key')), 35 | chartEnabled: z 36 | .boolean() 37 | .optional() 38 | .describe( 39 | t('TOOL_UPDATE_PROJECT_CHART_ENABLED', 'Whether to enable chart') 40 | ), 41 | subtaskingEnabled: z 42 | .boolean() 43 | .optional() 44 | .describe( 45 | t( 46 | 'TOOL_UPDATE_PROJECT_SUBTASKING_ENABLED', 47 | 'Whether to enable subtasking' 48 | ) 49 | ), 50 | projectLeaderCanEditProjectLeader: z 51 | .boolean() 52 | .optional() 53 | .describe( 54 | t( 55 | 'TOOL_UPDATE_PROJECT_LEADER_CAN_EDIT', 56 | 'Whether project leaders can edit other project leaders' 57 | ) 58 | ), 59 | textFormattingRule: z 60 | .enum(['backlog', 'markdown']) 61 | .optional() 62 | .describe(t('TOOL_UPDATE_PROJECT_TEXT_FORMATTING', 'Text formatting rule')), 63 | archived: z 64 | .boolean() 65 | .optional() 66 | .describe( 67 | t('TOOL_UPDATE_PROJECT_ARCHIVED', 'Whether to archive the project') 68 | ), 69 | })); 70 | 71 | export const updateProjectTool = ( 72 | backlog: Backlog, 73 | { t }: TranslationHelper 74 | ): ToolDefinition< 75 | ReturnType<typeof updateProjectSchema>, 76 | (typeof ProjectSchema)['shape'] 77 | > => { 78 | return { 79 | name: 'update_project', 80 | description: t( 81 | 'TOOL_UPDATE_PROJECT_DESCRIPTION', 82 | 'Updates an existing project' 83 | ), 84 | schema: z.object(updateProjectSchema(t)), 85 | outputSchema: ProjectSchema, 86 | handler: async ({ projectId, projectKey, ...param }) => { 87 | const result = resolveIdOrKey( 88 | 'project', 89 | { id: projectId, key: projectKey }, 90 | t 91 | ); 92 | if (!result.ok) { 93 | throw result.error; 94 | } 95 | return backlog.patchProject(result.value, param); 96 | }, 97 | }; 98 | }; 99 | ``` -------------------------------------------------------------------------------- /src/tools/updateProject.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { updateProjectTool } from './updateProject.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('updateProjectTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | patchProject: jest.fn<() => Promise<any>>().mockResolvedValue({ 9 | id: 1, 10 | projectKey: 'UPDATED', 11 | name: 'Updated Project', 12 | chartEnabled: true, 13 | subtaskingEnabled: true, 14 | projectLeaderCanEditProjectLeader: false, 15 | textFormattingRule: 'markdown', 16 | archived: true, 17 | displayOrder: 0, 18 | }), 19 | }; 20 | 21 | const mockTranslationHelper = createTranslationHelper(); 22 | const tool = updateProjectTool(mockBacklog as Backlog, mockTranslationHelper); 23 | 24 | it('returns updated project', async () => { 25 | const result = await tool.handler({ 26 | projectKey: 'TEST', 27 | name: 'Updated Project', 28 | key: 'UPDATED', 29 | archived: true, 30 | }); 31 | 32 | expect(result).toHaveProperty('name', 'Updated Project'); 33 | expect(result).toHaveProperty('projectKey', 'UPDATED'); 34 | expect(result).toHaveProperty('archived', true); 35 | }); 36 | 37 | it('calls backlog.patchProject with correct params when using project key', async () => { 38 | await tool.handler({ 39 | projectKey: 'TEST', 40 | name: 'Updated Project', 41 | key: 'UPDATED', 42 | textFormattingRule: 'markdown', 43 | archived: true, 44 | }); 45 | 46 | expect(mockBacklog.patchProject).toHaveBeenCalledWith('TEST', { 47 | name: 'Updated Project', 48 | key: 'UPDATED', 49 | chartEnabled: undefined, 50 | subtaskingEnabled: undefined, 51 | projectLeaderCanEditProjectLeader: undefined, 52 | textFormattingRule: 'markdown', 53 | archived: true, 54 | }); 55 | }); 56 | 57 | it('calls backlog.patchProject with correct params when using project ID', async () => { 58 | await tool.handler({ 59 | projectId: 1, 60 | chartEnabled: true, 61 | subtaskingEnabled: true, 62 | }); 63 | 64 | expect(mockBacklog.patchProject).toHaveBeenCalledWith(1, { 65 | name: undefined, 66 | key: undefined, 67 | chartEnabled: true, 68 | subtaskingEnabled: true, 69 | projectLeaderCanEditProjectLeader: undefined, 70 | textFormattingRule: undefined, 71 | archived: undefined, 72 | }); 73 | }); 74 | 75 | it('throws an error if neither projectId nor projectKey is provided', async () => { 76 | const params = { 77 | // projectId and projectKey are missing 78 | name: 'Test Project Name', 79 | }; 80 | 81 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 82 | }); 83 | }); 84 | ``` -------------------------------------------------------------------------------- /src/tools/getDocuments.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getDocumentsTool } from './getDocuments.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('getDocumentsTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | getDocuments: jest.fn<() => Promise<any>>().mockResolvedValue([ 9 | { 10 | id: 1, 11 | projectId: 100, 12 | title: 'Test Document 1', 13 | content: 'This is a test document.', 14 | createdUser: { 15 | id: 1, 16 | userId: 'admin', 17 | name: 'Admin User', 18 | roleType: 1, 19 | lang: 'en', 20 | mailAddress: '[email protected]', 21 | lastLoginTime: '2023-01-01T00:00:00Z', 22 | }, 23 | created: '2023-01-01T00:00:00Z', 24 | updatedUser: { 25 | id: 1, 26 | userId: 'admin', 27 | name: 'Admin User', 28 | roleType: 1, 29 | lang: 'en', 30 | mailAddress: '[email protected]', 31 | lastLoginTime: '2023-01-01T00:00:00Z', 32 | }, 33 | updated: '2023-01-01T00:00:00Z', 34 | }, 35 | { 36 | id: 2, 37 | projectId: 100, 38 | title: 'Test Document 2', 39 | content: 'This is another test document.', 40 | createdUser: { 41 | id: 1, 42 | userId: 'admin', 43 | name: 'Admin User', 44 | roleType: 1, 45 | lang: 'en', 46 | mailAddress: '[email protected]', 47 | lastLoginTime: '2023-01-01T00:00:00Z', 48 | }, 49 | created: '2023-01-01T00:00:00Z', 50 | updatedUser: { 51 | id: 1, 52 | userId: 'admin', 53 | name: 'Admin User', 54 | roleType: 1, 55 | lang: 'en', 56 | mailAddress: '[email protected]', 57 | lastLoginTime: '2023-01-01T00:00:00Z', 58 | }, 59 | updated: '2023-01-01T00:00:00Z', 60 | }, 61 | ]), 62 | }; 63 | 64 | const mockTranslationHelper = createTranslationHelper(); 65 | const tool = getDocumentsTool(mockBacklog as Backlog, mockTranslationHelper); 66 | 67 | it('returns a list of documents as formatted JSON text', async () => { 68 | const result = await tool.handler({ projectIds: [11], offset: 0 }); 69 | if (!Array.isArray(result)) { 70 | throw new Error('Unexpected non-array result'); 71 | } 72 | 73 | expect(result).toHaveLength(2); 74 | expect(result[0].title).toContain('Test Document 1'); 75 | }); 76 | 77 | it('calls backlog.getDocuments with correct params', async () => { 78 | await tool.handler({ projectIds: [11], offset: 0 }); 79 | 80 | expect(mockBacklog.getDocuments).toHaveBeenCalledWith({ 81 | projectId: [11], 82 | offset: 0, 83 | }); 84 | }); 85 | }); 86 | ``` -------------------------------------------------------------------------------- /src/tools/countIssues.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { countIssuesTool } from './countIssues.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('countIssuesTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | getIssuesCount: jest.fn<() => Promise<any>>().mockResolvedValue({ 9 | count: 42, 10 | }), 11 | }; 12 | 13 | const mockTranslationHelper = createTranslationHelper(); 14 | const tool = countIssuesTool(mockBacklog as Backlog, mockTranslationHelper); 15 | 16 | it('returns issue count', async () => { 17 | const result = await tool.handler({ 18 | projectId: [100], 19 | }); 20 | 21 | expect(result).toHaveProperty('count', 42); 22 | }); 23 | 24 | it('calls backlog.getIssuesCount with correct params', async () => { 25 | const params = { 26 | projectId: [100], 27 | statusId: [1], 28 | keyword: 'bug', 29 | }; 30 | 31 | await tool.handler(params); 32 | 33 | expect(mockBacklog.getIssuesCount).toHaveBeenCalledWith(params); 34 | }); 35 | 36 | it('calls backlog.getIssuesCount with date filters', async () => { 37 | await tool.handler({ 38 | createdSince: '2023-01-01', 39 | createdUntil: '2023-01-31', 40 | }); 41 | 42 | expect(mockBacklog.getIssuesCount).toHaveBeenCalledWith({ 43 | createdSince: '2023-01-01', 44 | createdUntil: '2023-01-31', 45 | }); 46 | }); 47 | 48 | it('calls backlog.getIssuesCount with custom fields', async () => { 49 | await tool.handler({ 50 | projectId: [100], 51 | customFields: [ 52 | { id: 12345, value: 'test-value' }, 53 | { id: 67890, value: 123 }, 54 | ], 55 | }); 56 | 57 | expect(mockBacklog.getIssuesCount).toHaveBeenCalledWith({ 58 | projectId: [100], 59 | customField_12345: 'test-value', 60 | customField_67890: 123, 61 | }); 62 | }); 63 | 64 | it('calls backlog.getIssuesCount with custom fields array values', async () => { 65 | await tool.handler({ 66 | customFields: [{ id: 11111, value: ['option1', 'option2'] }], 67 | }); 68 | 69 | expect(mockBacklog.getIssuesCount).toHaveBeenCalledWith({ 70 | customField_11111: ['option1', 'option2'], 71 | }); 72 | }); 73 | 74 | it('calls backlog.getIssuesCount with empty custom fields', async () => { 75 | await tool.handler({ 76 | projectId: [100], 77 | customFields: [], 78 | }); 79 | 80 | expect(mockBacklog.getIssuesCount).toHaveBeenCalledWith({ 81 | projectId: [100], 82 | }); 83 | }); 84 | 85 | it('calls backlog.getIssuesCount without custom fields', async () => { 86 | await tool.handler({ 87 | projectId: [100], 88 | statusId: [1], 89 | }); 90 | 91 | expect(mockBacklog.getIssuesCount).toHaveBeenCalledWith({ 92 | projectId: [100], 93 | statusId: [1], 94 | }); 95 | }); 96 | }); 97 | ``` -------------------------------------------------------------------------------- /src/tools/addPullRequestComment.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { Backlog } from 'backlog-js'; 3 | import { buildToolSchema, ToolDefinition } from '../types/tool.js'; 4 | import { TranslationHelper } from '../createTranslationHelper.js'; 5 | import { PullRequestCommentSchema } from '../types/zod/backlogOutputDefinition.js'; 6 | import { resolveIdOrKey, resolveIdOrName } from '../utils/resolveIdOrKey.js'; 7 | 8 | const addPullRequestCommentSchema = buildToolSchema((t) => ({ 9 | projectId: z 10 | .number() 11 | .optional() 12 | .describe( 13 | t( 14 | 'TOOL_ADD_PULL_REQUEST_COMMENT_PROJECT_ID', 15 | 'The numeric ID of the project (e.g., 12345)' 16 | ) 17 | ), 18 | projectKey: z 19 | .string() 20 | .optional() 21 | .describe( 22 | t( 23 | 'TOOL_ADD_PULL_REQUEST_COMMENT_PROJECT_KEY', 24 | "The key of the project (e.g., 'PROJECT')" 25 | ) 26 | ), 27 | repoId: z 28 | .number() 29 | .optional() 30 | .describe(t('TOOL_ADD_PULL_REQUEST_REPO_ID', 'Repository ID')), 31 | repoName: z 32 | .string() 33 | .optional() 34 | .describe(t('TOOL_ADD_PULL_REQUEST_REPO_NAME', 'Repository name')), 35 | number: z 36 | .number() 37 | .describe(t('TOOL_ADD_PULL_REQUEST_COMMENT_NUMBER', 'Pull request number')), 38 | content: z 39 | .string() 40 | .describe(t('TOOL_ADD_PULL_REQUEST_COMMENT_CONTENT', 'Comment content')), 41 | notifiedUserId: z 42 | .array(z.number()) 43 | .optional() 44 | .describe( 45 | t('TOOL_ADD_PULL_REQUEST_COMMENT_NOTIFIED_USER_ID', 'User IDs to notify') 46 | ), 47 | })); 48 | 49 | export const addPullRequestCommentTool = ( 50 | backlog: Backlog, 51 | { t }: TranslationHelper 52 | ): ToolDefinition< 53 | ReturnType<typeof addPullRequestCommentSchema>, 54 | (typeof PullRequestCommentSchema)['shape'] 55 | > => { 56 | return { 57 | name: 'add_pull_request_comment', 58 | description: t( 59 | 'TOOL_ADD_PULL_REQUEST_COMMENT_DESCRIPTION', 60 | 'Adds a comment to a pull request' 61 | ), 62 | schema: z.object(addPullRequestCommentSchema(t)), 63 | outputSchema: PullRequestCommentSchema, 64 | importantFields: ['id', 'content', 'createdUser'], 65 | handler: async ({ 66 | projectId, 67 | projectKey, 68 | repoId, 69 | repoName, 70 | number, 71 | ...params 72 | }) => { 73 | const result = resolveIdOrKey( 74 | 'project', 75 | { id: projectId, key: projectKey }, 76 | t 77 | ); 78 | if (!result.ok) { 79 | throw result.error; 80 | } 81 | const repoRes = resolveIdOrName( 82 | 'repository', 83 | { id: repoId, name: repoName }, 84 | t 85 | ); 86 | if (!repoRes.ok) { 87 | throw repoRes.error; 88 | } 89 | return backlog.postPullRequestComments( 90 | result.value, 91 | String(repoRes.value), 92 | number, 93 | params 94 | ); 95 | }, 96 | }; 97 | }; 98 | ``` -------------------------------------------------------------------------------- /src/utils/wrapServerWithToolRegistry.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, expect, it, jest, beforeEach } from '@jest/globals'; 2 | import { z } from 'zod'; 3 | import { wrapServerWithToolRegistry } from './wrapServerWithToolRegistry'; 4 | 5 | describe('wrapServerWithToolRegistry', () => { 6 | let mockServer: any; 7 | let toolCalls: Array<{ name: string; description: string }> = []; 8 | 9 | beforeEach(() => { 10 | toolCalls = []; 11 | 12 | mockServer = { 13 | tool: jest.fn((name: string, description: string) => { 14 | toolCalls.push({ name, description }); 15 | }), 16 | }; 17 | }); 18 | 19 | it('registers a tool when not already registered', () => { 20 | const server = wrapServerWithToolRegistry(mockServer); 21 | const schema = z.object({}).shape; 22 | 23 | server.registerOnce('hello', 'test tool', schema, () => ({ 24 | content: [{ type: 'text', text: 'ok' }], 25 | })); 26 | 27 | expect(toolCalls).toHaveLength(1); 28 | expect(toolCalls[0].name).toBe('hello'); 29 | }); 30 | 31 | it('skips duplicate registration', () => { 32 | const server = wrapServerWithToolRegistry(mockServer); 33 | const schema = z.object({}).shape; 34 | 35 | server.registerOnce('dup', 'first', schema, () => ({ 36 | content: [{ type: 'text', text: 'ok' }], 37 | })); 38 | server.registerOnce('dup', 'second', schema, () => ({ 39 | content: [{ type: 'text', text: 'ok' }], 40 | })); 41 | 42 | expect(toolCalls).toHaveLength(1); 43 | expect(toolCalls[0].name).toBe('dup'); 44 | expect(toolCalls[0].description).toBe('first'); 45 | }); 46 | 47 | it('does not throw if registerOnce is called twice with same name', () => { 48 | const server = wrapServerWithToolRegistry(mockServer); 49 | const schema = z.object({}).shape; 50 | 51 | expect(() => { 52 | server.registerOnce('toolX', 'desc1', schema, () => ({ 53 | content: [{ type: 'text', text: 'ok' }], 54 | })); 55 | server.registerOnce('toolX', 'desc2', schema, () => ({ 56 | content: [{ type: 'text', text: 'ok' }], 57 | })); 58 | }).not.toThrow(); 59 | 60 | expect(toolCalls).toHaveLength(1); 61 | }); 62 | 63 | it('adds __registeredToolNames to server', () => { 64 | const server = wrapServerWithToolRegistry(mockServer); 65 | expect(server.__registeredToolNames).toBeInstanceOf(Set); 66 | }); 67 | 68 | it('registers multiple distinct tools', () => { 69 | const server = wrapServerWithToolRegistry(mockServer); 70 | const schema = z.object({}).shape; 71 | 72 | server.registerOnce('tool1', 'desc1', schema, () => ({ 73 | content: [{ type: 'text', text: 'ok' }], 74 | })); 75 | server.registerOnce('tool2', 'desc2', schema, () => ({ 76 | content: [{ type: 'text', text: 'ok' }], 77 | })); 78 | 79 | expect(toolCalls).toHaveLength(2); 80 | expect(toolCalls[0].name).toBe('tool1'); 81 | expect(toolCalls[1].name).toBe('tool2'); 82 | }); 83 | }); 84 | ``` -------------------------------------------------------------------------------- /src/tools/getIssueComments.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getIssueCommentsTool } from './getIssueComments.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('getIssueCommentsTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | getIssueComments: jest.fn<() => Promise<any>>().mockResolvedValue([ 9 | { 10 | id: 1, 11 | content: 'This is the first comment', 12 | changeLog: [], 13 | createdUser: { 14 | id: 1, 15 | userId: 'admin', 16 | name: 'Admin User', 17 | roleType: 1, 18 | lang: 'en', 19 | mailAddress: '[email protected]', 20 | lastLoginTime: '2023-01-01T00:00:00Z', 21 | }, 22 | created: '2023-01-01T00:00:00Z', 23 | updated: '2023-01-01T00:00:00Z', 24 | }, 25 | { 26 | id: 2, 27 | content: 'This is the second comment', 28 | changeLog: [], 29 | createdUser: { 30 | id: 5, 31 | userId: 'user', 32 | name: 'Test User', 33 | roleType: 1, 34 | lang: 'en', 35 | mailAddress: '[email protected]', 36 | lastLoginTime: '2023-01-01T00:00:00Z', 37 | }, 38 | created: '2023-01-02T00:00:00Z', 39 | updated: '2023-01-02T00:00:00Z', 40 | }, 41 | ]), 42 | }; 43 | 44 | const mockTranslationHelper = createTranslationHelper(); 45 | const tool = getIssueCommentsTool( 46 | mockBacklog as Backlog, 47 | mockTranslationHelper 48 | ); 49 | 50 | it('returns issue comments', async () => { 51 | const result = await tool.handler({ 52 | issueKey: 'TEST-1', 53 | }); 54 | 55 | if (!Array.isArray(result)) { 56 | throw new Error('Unexpected non array result'); 57 | } 58 | 59 | expect(result).toHaveLength(2); 60 | expect(result[0]).toHaveProperty('content', 'This is the first comment'); 61 | expect(result[1]).toHaveProperty('content', 'This is the second comment'); 62 | }); 63 | 64 | it('calls backlog.getIssueComments with correct params when using issue key', async () => { 65 | await tool.handler({ 66 | issueKey: 'TEST-1', 67 | count: 10, 68 | order: 'desc', 69 | }); 70 | 71 | expect(mockBacklog.getIssueComments).toHaveBeenCalledWith('TEST-1', { 72 | count: 10, 73 | order: 'desc', 74 | }); 75 | }); 76 | 77 | it('calls backlog.getIssueComments with correct params when using issue ID and min/max IDs', async () => { 78 | await tool.handler({ 79 | issueId: 1, 80 | minId: 100, 81 | maxId: 200, 82 | }); 83 | 84 | expect(mockBacklog.getIssueComments).toHaveBeenCalledWith(1, { 85 | // Expect number 86 | minId: 100, 87 | maxId: 200, 88 | }); 89 | }); 90 | 91 | it('throws an error if neither issueId nor issueKey is provided', async () => { 92 | await expect(tool.handler({})).rejects.toThrow(Error); 93 | }); 94 | }); 95 | ``` -------------------------------------------------------------------------------- /src/tools/getPullRequestsCount.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { Backlog } from 'backlog-js'; 3 | import { buildToolSchema, ToolDefinition } from '../types/tool.js'; 4 | import { TranslationHelper } from '../createTranslationHelper.js'; 5 | import { PullRequestCountSchema } from '../types/zod/backlogOutputDefinition.js'; 6 | import { resolveIdOrKey, resolveIdOrName } from '../utils/resolveIdOrKey.js'; 7 | 8 | const getPullRequestsCountSchema = buildToolSchema((t) => ({ 9 | projectId: z 10 | .number() 11 | .optional() 12 | .describe( 13 | t( 14 | 'TOOL_GET_PULL_REQUESTS_COUNT_PROJECT_ID', 15 | 'The numeric ID of the project (e.g., 12345)' 16 | ) 17 | ), 18 | projectKey: z 19 | .string() 20 | .optional() 21 | .describe( 22 | t( 23 | 'TOOL_GET_PULL_REQUESTS_COUNT_PROJECT_KEY', 24 | "The key of the project (e.g., 'PROJECT')" 25 | ) 26 | ), 27 | repoId: z 28 | .number() 29 | .optional() 30 | .describe(t('TOOL_GET_PULL_REQUESTS_COUNT_REPO_ID', 'Repository ID')), 31 | repoName: z 32 | .string() 33 | .optional() 34 | .describe(t('TOOL_GET_PULL_REQUESTS_COUNT_REPO_NAME', 'Repository name')), 35 | statusId: z 36 | .array(z.number()) 37 | .optional() 38 | .describe(t('TOOL_GET_PULL_REQUESTS_COUNT_STATUS_ID', 'Status IDs')), 39 | assigneeId: z 40 | .array(z.number()) 41 | .optional() 42 | .describe( 43 | t('TOOL_GET_PULL_REQUESTS_COUNT_ASSIGNEE_ID', 'Assignee user IDs') 44 | ), 45 | issueId: z 46 | .array(z.number()) 47 | .optional() 48 | .describe(t('TOOL_GET_PULL_REQUESTS_COUNT_ISSUE_ID', 'Issue IDs')), 49 | createdUserId: z 50 | .array(z.number()) 51 | .optional() 52 | .describe( 53 | t('TOOL_GET_PULL_REQUESTS_COUNT_CREATED_USER_ID', 'Created user IDs') 54 | ), 55 | })); 56 | 57 | export const getPullRequestsCountTool = ( 58 | backlog: Backlog, 59 | { t }: TranslationHelper 60 | ): ToolDefinition< 61 | ReturnType<typeof getPullRequestsCountSchema>, 62 | (typeof PullRequestCountSchema)['shape'] 63 | > => { 64 | return { 65 | name: 'get_pull_requests_count', 66 | description: t( 67 | 'TOOL_GET_PULL_REQUESTS_COUNT_DESCRIPTION', 68 | 'Returns count of pull requests for a repository' 69 | ), 70 | schema: z.object(getPullRequestsCountSchema(t)), 71 | outputSchema: PullRequestCountSchema, 72 | handler: async ({ projectId, projectKey, repoId, repoName, ...params }) => { 73 | const result = resolveIdOrKey( 74 | 'project', 75 | { id: projectId, key: projectKey }, 76 | t 77 | ); 78 | if (!result.ok) { 79 | throw result.error; 80 | } 81 | const repoResult = resolveIdOrName( 82 | 'repository', 83 | { id: repoId, name: repoName }, 84 | t 85 | ); 86 | if (!repoResult.ok) { 87 | throw repoResult.error; 88 | } 89 | return backlog.getPullRequestsCount( 90 | result.value, 91 | String(repoResult.value), 92 | params 93 | ); 94 | }, 95 | }; 96 | }; 97 | ``` -------------------------------------------------------------------------------- /src/tools/updatePullRequestComment.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { Backlog } from 'backlog-js'; 3 | import { buildToolSchema, ToolDefinition } from '../types/tool.js'; 4 | import { TranslationHelper } from '../createTranslationHelper.js'; 5 | import { PullRequestCommentSchema } from '../types/zod/backlogOutputDefinition.js'; 6 | import { resolveIdOrKey, resolveIdOrName } from '../utils/resolveIdOrKey.js'; 7 | 8 | const updatePullRequestCommentSchema = buildToolSchema((t) => ({ 9 | projectId: z 10 | .number() 11 | .optional() 12 | .describe( 13 | t( 14 | 'TOOL_UPDATE_PULL_REQUEST_COMMENT_PROJECT_ID', 15 | 'The numeric ID of the project (e.g., 12345)' 16 | ) 17 | ), 18 | projectKey: z 19 | .string() 20 | .optional() 21 | .describe( 22 | t( 23 | 'TOOL_UPDATE_PULL_REQUEST_COMMENT_PROJECT_KEY', 24 | "The key of the project (e.g., 'PROJECT')" 25 | ) 26 | ), 27 | repoId: z 28 | .number() 29 | .optional() 30 | .describe(t('TOOL_UPDATE_PULL_REQUEST_COMMENT_REPO_ID', 'Repository ID')), 31 | repoName: z 32 | .string() 33 | .optional() 34 | .describe( 35 | t('TOOL_UPDATE_PULL_REQUEST_COMMENT_REPO_NAME', 'Repository name') 36 | ), 37 | number: z 38 | .number() 39 | .describe( 40 | t('TOOL_UPDATE_PULL_REQUEST_COMMENT_NUMBER', 'Pull request number') 41 | ), 42 | commentId: z 43 | .number() 44 | .describe(t('TOOL_UPDATE_PULL_REQUEST_COMMENT_COMMENT_ID', 'Comment ID')), 45 | content: z 46 | .string() 47 | .describe(t('TOOL_UPDATE_PULL_REQUEST_COMMENT_CONTENT', 'Comment content')), 48 | })); 49 | 50 | export const updatePullRequestCommentTool = ( 51 | backlog: Backlog, 52 | { t }: TranslationHelper 53 | ): ToolDefinition< 54 | ReturnType<typeof updatePullRequestCommentSchema>, 55 | (typeof PullRequestCommentSchema)['shape'] 56 | > => { 57 | return { 58 | name: 'update_pull_request_comment', 59 | description: t( 60 | 'TOOL_UPDATE_PULL_REQUEST_COMMENT_DESCRIPTION', 61 | 'Updates a comment on a pull request' 62 | ), 63 | schema: z.object(updatePullRequestCommentSchema(t)), 64 | outputSchema: PullRequestCommentSchema, 65 | importantFields: ['id', 'content', 'createdUser', 'updated'], 66 | handler: async ({ 67 | projectId, 68 | projectKey, 69 | repoId, 70 | repoName, 71 | number, 72 | commentId, 73 | content, 74 | }) => { 75 | const result = resolveIdOrKey( 76 | 'project', 77 | { id: projectId, key: projectKey }, 78 | t 79 | ); 80 | if (!result.ok) { 81 | throw result.error; 82 | } 83 | const repoResult = resolveIdOrName( 84 | 'repository', 85 | { id: repoId, name: repoName }, 86 | t 87 | ); 88 | if (!repoResult.ok) { 89 | throw repoResult.error; 90 | } 91 | return backlog.patchPullRequestComments( 92 | result.value, 93 | String(repoResult.value), 94 | number, 95 | commentId, 96 | { content } 97 | ); 98 | }, 99 | }; 100 | }; 101 | ``` -------------------------------------------------------------------------------- /src/tools/getDocument.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getDocumentTool } from './getDocument.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('getDocumentTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | getDocument: jest.fn<() => Promise<any>>().mockResolvedValue({ 9 | id: '019347fc760c7b0abff04b44628c94d7', 10 | projectId: 1, 11 | title: 'Test Document', 12 | plain: 'This is a test document.', 13 | json: '{}', 14 | statusId: 1, 15 | emoji: null, 16 | attachments: [ 17 | { 18 | id: 22067, 19 | name: 'test.png', 20 | size: 8718, 21 | createdUser: { 22 | id: 3, 23 | userId: 'woody', 24 | name: 'woody', 25 | roleType: 2, 26 | lang: 'ja', 27 | mailAddress: '[email protected]', 28 | nulabAccount: { 29 | nulabId: 'aaa', 30 | name: 'woody', 31 | uniqueId: 'woody', 32 | iconUrl: 'https://photo', 33 | }, 34 | keyword: 'woody', 35 | lastLoginTime: '2025-05-22T23:04:03Z', 36 | }, 37 | created: '2025-05-29T02:19:54Z', 38 | }, 39 | ], 40 | tags: [ 41 | { 42 | id: 1, 43 | name: 'Backlog', 44 | }, 45 | ], 46 | createdUser: { 47 | id: 2, 48 | userId: 'woody', 49 | name: 'woody', 50 | roleType: 1, 51 | lang: 'en', 52 | mailAddress: '[email protected]', 53 | nulabAccount: null, 54 | keyword: 'Woody', 55 | lastLoginTime: '2025-05-28T22:24:36Z', 56 | }, 57 | created: '2024-12-06T01:08:56Z', 58 | updatedUser: { 59 | id: 2, 60 | userId: 'woody', 61 | name: 'woody', 62 | roleType: 1, 63 | lang: 'en', 64 | mailAddress: '[email protected]', 65 | nulabAccount: null, 66 | keyword: 'Woody', 67 | lastLoginTime: '2025-05-28T22:24:36Z', 68 | }, 69 | updated: '2025-04-28T01:47:02Z', 70 | }), 71 | }; 72 | 73 | const mockTranslationHelper = createTranslationHelper(); 74 | const tool = getDocumentTool(mockBacklog as Backlog, mockTranslationHelper); 75 | 76 | it('returns document as formatted JSON text', async () => { 77 | const result = await tool.handler({ 78 | documentId: '019347fc760c7b0abff04b44628c94d7', 79 | }); 80 | if (Array.isArray(result)) { 81 | throw new Error('Unexpected array result'); 82 | } 83 | 84 | expect(result.title).toContain('Test Document'); 85 | expect(result.plain).toContain('This is a test document.'); 86 | }); 87 | 88 | it('calls backlog.getDocument with correct params', async () => { 89 | await tool.handler({ documentId: '019347fc760c7b0abff04b44628c94d7' }); 90 | 91 | expect(mockBacklog.getDocument).toHaveBeenCalledWith( 92 | '019347fc760c7b0abff04b44628c94d7' 93 | ); 94 | }); 95 | }); 96 | ``` -------------------------------------------------------------------------------- /src/tools/getProjectList.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getProjectListTool } from './getProjectList.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog, Entity } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('getProjectListTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | getProjects: jest 9 | .fn<() => Promise<Entity.Project.Project[]>>() 10 | .mockResolvedValue([ 11 | { 12 | id: 1, 13 | name: 'Project A', 14 | projectKey: '', 15 | chartEnabled: false, 16 | useResolvedForChart: false, 17 | subtaskingEnabled: false, 18 | projectLeaderCanEditProjectLeader: false, 19 | useWiki: false, 20 | useFileSharing: false, 21 | useWikiTreeView: false, 22 | useOriginalImageSizeAtWiki: false, 23 | useSubversion: false, 24 | useGit: false, 25 | textFormattingRule: 'backlog', 26 | archived: false, 27 | displayOrder: 0, 28 | useDevAttributes: false, 29 | }, 30 | { 31 | id: 2, 32 | name: 'Project B', 33 | projectKey: '', 34 | chartEnabled: false, 35 | useResolvedForChart: false, 36 | subtaskingEnabled: false, 37 | projectLeaderCanEditProjectLeader: false, 38 | useWiki: false, 39 | useFileSharing: false, 40 | useWikiTreeView: false, 41 | useOriginalImageSizeAtWiki: false, 42 | useSubversion: false, 43 | useGit: false, 44 | textFormattingRule: 'backlog', 45 | archived: false, 46 | displayOrder: 0, 47 | useDevAttributes: false, 48 | }, 49 | ]), 50 | }; 51 | const { t, dump } = createTranslationHelper(); 52 | 53 | const tool = getProjectListTool(mockBacklog as Backlog, { t, dump }); 54 | 55 | it('returns project list as formatted JSON text', async () => { 56 | const result = await tool.handler({ archived: false, all: true }); 57 | 58 | if (!Array.isArray(result)) { 59 | throw new Error('Unexpected non array result'); 60 | } 61 | expect(result).toHaveLength(2); 62 | expect(result[0].name).toContain('Project A'); 63 | expect(result[1].name).toContain('Project B'); 64 | }); 65 | 66 | it('calls backlog.getProjects with correct params', async () => { 67 | await tool.handler({ archived: true, all: false }); 68 | expect(mockBacklog.getProjects).toHaveBeenCalledWith({ 69 | archived: true, 70 | all: false, 71 | }); 72 | }); 73 | 74 | it('has correct key for translated description', () => { 75 | expect(tool.description).toBe(t('TOOL_GET_PROJECT_LIST_DESCRIPTION', '')); 76 | }); 77 | 78 | it('has correct key for schema field descriptions', () => { 79 | const shape = tool.schema.shape; 80 | expect(shape.archived.description).toBe( 81 | t('TOOL_GET_PROJECT_LIST_ARCHIVED', '') 82 | ); 83 | expect(shape.all.description).toBe(t('TOOL_GET_PROJECT_LIST_ALL', '')); 84 | }); 85 | }); 86 | ``` -------------------------------------------------------------------------------- /src/tools/getPullRequestsCount.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getPullRequestsCountTool } from './getPullRequestsCount.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('getPullRequestsCountTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | getPullRequestsCount: jest.fn<() => Promise<any>>().mockResolvedValue({ 9 | count: 42, 10 | }), 11 | }; 12 | 13 | const mockTranslationHelper = createTranslationHelper(); 14 | const tool = getPullRequestsCountTool( 15 | mockBacklog as Backlog, 16 | mockTranslationHelper 17 | ); 18 | 19 | it('returns pull requests count', async () => { 20 | const result = await tool.handler({ 21 | projectKey: 'TEST', 22 | repoName: 'test-repo', // Changed 23 | }); 24 | 25 | expect(result).toHaveProperty('count', 42); 26 | }); 27 | 28 | it('calls backlog.getPullRequestsCount with correct params when using repoName', async () => { 29 | const params = { 30 | projectKey: 'TEST', 31 | repoName: 'test-repo', // Changed 32 | statusId: [1, 2], 33 | assigneeId: [1], 34 | }; 35 | 36 | await tool.handler(params); 37 | 38 | expect(mockBacklog.getPullRequestsCount).toHaveBeenCalledWith( 39 | 'TEST', 40 | 'test-repo', 41 | { 42 | statusId: [1, 2], 43 | assigneeId: [1], 44 | } 45 | ); 46 | }); 47 | 48 | it('calls backlog.getPullRequestsCount with correct params when using projectId and repoName', async () => { 49 | const params = { 50 | projectId: 100, 51 | repoName: 'test-repo', // Changed 52 | statusId: [1], 53 | }; 54 | 55 | await tool.handler(params); 56 | 57 | expect(mockBacklog.getPullRequestsCount).toHaveBeenCalledWith( 58 | 100, 59 | 'test-repo', 60 | { 61 | statusId: [1], 62 | assigneeId: undefined, 63 | createdUserId: undefined, 64 | issueId: undefined, 65 | } 66 | ); 67 | }); 68 | 69 | it('calls backlog.getPullRequestsCount with correct params when using projectId and repoId', async () => { 70 | const params = { 71 | projectId: 100, 72 | repoId: 200, // Added repoId 73 | statusId: [1], 74 | }; 75 | 76 | await tool.handler(params); 77 | 78 | expect(mockBacklog.getPullRequestsCount).toHaveBeenCalledWith(100, '200', { 79 | statusId: [1], 80 | assigneeId: undefined, 81 | createdUserId: undefined, 82 | issueId: undefined, 83 | }); 84 | }); 85 | 86 | it('throws an error if neither projectId nor projectKey is provided', async () => { 87 | const params = { 88 | // projectId and projectKey are missing 89 | repoName: 'test-repo', // Changed 90 | }; 91 | 92 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 93 | }); 94 | 95 | it('throws an error if neither repoId nor repoName is provided', async () => { 96 | const params = { 97 | projectKey: 'TEST', 98 | // repoId and repoName are missing 99 | }; 100 | 101 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 102 | }); 103 | }); 104 | ``` -------------------------------------------------------------------------------- /src/registerTools.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { registerTools } from './registerTools'; 2 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 3 | import { Backlog } from 'backlog-js'; 4 | import { TranslationHelper } from './createTranslationHelper'; 5 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 6 | import * as toolModule from './tools/tools'; 7 | import { buildToolsetGroup } from './utils/toolsetUtils.js'; 8 | import { wrapServerWithToolRegistry } from './utils/wrapServerWithToolRegistry.js'; 9 | 10 | jest.mock('./handlers/builders/composeToolHandler'); 11 | jest.mock('./tools/tools'); 12 | 13 | describe('registerTools', () => { 14 | const mockBacklog = {} as Backlog; 15 | const mockHelper = { 16 | t: jest.fn(), 17 | } as unknown as TranslationHelper; 18 | const toolsetGroup = toolModule.allTools(mockBacklog, mockHelper); 19 | const spaceToolSet = toolsetGroup.toolsets.find((a) => a.name === 'space'); 20 | if (spaceToolSet == null) { 21 | throw new Error(`Toolset "space" not found in allTools. Check test setup.`); 22 | } 23 | 24 | beforeEach(() => { 25 | jest.clearAllMocks(); 26 | }); 27 | 28 | it('registers tools from enabled toolsets only', () => { 29 | const mockServer = wrapServerWithToolRegistry({ 30 | tool: jest.fn(), 31 | } as unknown as McpServer); 32 | const toolsetGroup = buildToolsetGroup(mockBacklog, mockHelper, ['space']); 33 | 34 | registerTools(mockServer, toolsetGroup, { 35 | useFields: false, 36 | prefix: '', 37 | maxTokens: 5000, 38 | }); 39 | expect(mockServer.tool).toHaveBeenCalledTimes(spaceToolSet.tools.length); 40 | const calledToolNames = (mockServer.tool as jest.Mock).mock.calls.map( 41 | (call) => call[0] 42 | ); 43 | expect(calledToolNames).toEqual( 44 | expect.arrayContaining(spaceToolSet.tools.map((a) => a.name)) 45 | ); 46 | }); 47 | 48 | it('applies prefix to tool name', () => { 49 | const mockServer = wrapServerWithToolRegistry({ 50 | tool: jest.fn(), 51 | } as unknown as McpServer); 52 | const toolsetGroup = buildToolsetGroup(mockBacklog, mockHelper, ['space']); 53 | registerTools(mockServer, toolsetGroup, { 54 | useFields: false, 55 | prefix: 'backlog.', 56 | maxTokens: 5000, 57 | }); 58 | 59 | const calledToolNames = (mockServer.tool as jest.Mock).mock.calls.map( 60 | (call) => call[0] 61 | ); 62 | expect(calledToolNames).toEqual( 63 | expect.arrayContaining(spaceToolSet.tools.map((a) => `backlog.${a.name}`)) 64 | ); 65 | }); 66 | 67 | it('enables all toolsets when "all" is specified', () => { 68 | const mockServer = wrapServerWithToolRegistry({ 69 | tool: jest.fn(), 70 | } as unknown as McpServer); 71 | const toolsetGroup = buildToolsetGroup(mockBacklog, mockHelper, ['all']); 72 | registerTools(mockServer, toolsetGroup, { 73 | useFields: false, 74 | maxTokens: 1000, 75 | prefix: '', 76 | }); 77 | 78 | expect(mockServer.tool).toHaveBeenCalledTimes( 79 | toolsetGroup.toolsets.flatMap((a) => a.tools).length 80 | ); 81 | }); 82 | }); 83 | ``` -------------------------------------------------------------------------------- /src/utils/resolveIdOrKey.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { resolveIdOrKey, resolveIdOrName } from './resolveIdOrKey'; // Added resolveIdOrName and EntityName 2 | import { describe, it, expect } from '@jest/globals'; 3 | 4 | const t = (_key: string, fallback: string) => fallback; 5 | 6 | describe('resolveIdOrKey', () => { 7 | it('resolves ID when provided', () => { 8 | const result = resolveIdOrKey('issue', { id: 123 }, t); 9 | expect(result).toEqual({ ok: true, value: 123 }); // Expect number 10 | }); 11 | 12 | it('resolves key when ID is not provided', () => { 13 | const result = resolveIdOrKey('project', { key: 'PRJ-001' }, t); 14 | expect(result).toEqual({ ok: true, value: 'PRJ-001' }); 15 | }); 16 | 17 | it("returns error for 'project' when neither ID nor key is provided", () => { 18 | const result = resolveIdOrKey('project', {}, t); 19 | expect(result.ok).toBe(false); 20 | if (!result.ok) { 21 | expect(result.error.message).toBe('Project ID or key is required'); 22 | } 23 | }); 24 | 25 | it("resolves ID for 'repository'", () => { 26 | const result = resolveIdOrKey('repository', { id: 777 }, t); 27 | expect(result).toEqual({ ok: true, value: 777 }); 28 | }); 29 | 30 | it("returns error for 'repository' when neither ID nor key is provided", () => { 31 | const result = resolveIdOrKey('repository', {}, t); 32 | expect(result.ok).toBe(false); 33 | if (!result.ok) { 34 | expect(result.error.message).toBe('Repository ID or key is required'); 35 | } 36 | }); 37 | }); 38 | 39 | describe('resolveIdOrName', () => { 40 | it('resolves ID when provided', () => { 41 | const result = resolveIdOrName('issue', { id: 456 }, t); 42 | expect(result).toEqual({ ok: true, value: 456 }); 43 | }); 44 | 45 | it('resolves name when ID is not provided', () => { 46 | const result = resolveIdOrName('project', { name: 'MyProject' }, t); 47 | expect(result).toEqual({ ok: true, value: 'MyProject' }); 48 | }); 49 | 50 | it("returns error for 'repository' when neither ID nor name is provided", () => { 51 | const result = resolveIdOrName('repository', {}, t); 52 | expect(result.ok).toBe(false); 53 | if (!result.ok) { 54 | expect(result.error.message).toBe('Repository ID or name is required'); 55 | } 56 | }); 57 | 58 | it("resolves ID for 'git' entity (using name field)", () => { 59 | // 'git' might be an alias or specific use case for 'repository' that uses 'name' 60 | const result = resolveIdOrName('repository', { id: 888 }, t); 61 | expect(result).toEqual({ ok: true, value: 888 }); 62 | }); 63 | 64 | it("resolves name for 'git' entity", () => { 65 | const result = resolveIdOrName('repository', { name: 'main-repo' }, t); 66 | expect(result).toEqual({ ok: true, value: 'main-repo' }); 67 | }); 68 | 69 | it("returns error for 'git' when neither ID nor name is provided", () => { 70 | const result = resolveIdOrName('repository', {}, t); 71 | expect(result.ok).toBe(false); 72 | if (!result.ok) { 73 | expect(result.error.message).toBe('Repository ID or name is required'); 74 | } 75 | }); 76 | }); 77 | ``` -------------------------------------------------------------------------------- /src/tools/getPullRequests.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { Backlog } from 'backlog-js'; 3 | import { buildToolSchema, ToolDefinition } from '../types/tool.js'; 4 | import { TranslationHelper } from '../createTranslationHelper.js'; 5 | import { PullRequestSchema } from '../types/zod/backlogOutputDefinition.js'; 6 | import { resolveIdOrKey, resolveIdOrName } from '../utils/resolveIdOrKey.js'; 7 | 8 | const getPullRequestsSchema = buildToolSchema((t) => ({ 9 | projectId: z 10 | .number() 11 | .optional() 12 | .describe( 13 | t( 14 | 'TOOL_GET_PULL_REQUESTS_PROJECT_ID', 15 | 'The numeric ID of the project (e.g., 12345)' 16 | ) 17 | ), 18 | projectKey: z 19 | .string() 20 | .optional() 21 | .describe( 22 | t( 23 | 'TOOL_GET_PULL_REQUESTS_PROJECT_KEY', 24 | "The key of the project (e.g., 'PROJECT')" 25 | ) 26 | ), 27 | repoId: z 28 | .number() 29 | .optional() 30 | .describe(t('TOOL_GET_PULL_REQUESTS_REPO_ID', 'Repository ID')), 31 | repoName: z 32 | .string() 33 | .optional() 34 | .describe(t('TOOL_GET_PULL_REQUESTS_REPO_NAME', 'Repository name')), 35 | statusId: z 36 | .array(z.number()) 37 | .optional() 38 | .describe(t('TOOL_GET_PULL_REQUESTS_STATUS_ID', 'Status IDs')), 39 | assigneeId: z 40 | .array(z.number()) 41 | .optional() 42 | .describe(t('TOOL_GET_PULL_REQUESTS_ASSIGNEE_ID', 'Assignee user IDs')), 43 | issueId: z 44 | .array(z.number()) 45 | .optional() 46 | .describe(t('TOOL_GET_PULL_REQUESTS_ISSUE_ID', 'Issue IDs')), 47 | createdUserId: z 48 | .array(z.number()) 49 | .optional() 50 | .describe(t('TOOL_GET_PULL_REQUESTS_CREATED_USER_ID', 'Created user IDs')), 51 | offset: z 52 | .number() 53 | .optional() 54 | .describe(t('TOOL_GET_PULL_REQUESTS_OFFSET', 'Offset for pagination')), 55 | count: z 56 | .number() 57 | .optional() 58 | .describe( 59 | t('TOOL_GET_PULL_REQUESTS_COUNT', 'Number of pull requests to retrieve') 60 | ), 61 | })); 62 | 63 | export const getPullRequestsTool = ( 64 | backlog: Backlog, 65 | { t }: TranslationHelper 66 | ): ToolDefinition< 67 | ReturnType<typeof getPullRequestsSchema>, 68 | (typeof PullRequestSchema)['shape'] 69 | > => { 70 | return { 71 | name: 'get_pull_requests', 72 | description: t( 73 | 'TOOL_GET_PULL_REQUESTS_DESCRIPTION', 74 | 'Returns list of pull requests for a repository' 75 | ), 76 | schema: z.object(getPullRequestsSchema(t)), 77 | outputSchema: PullRequestSchema, 78 | handler: async ({ projectId, projectKey, repoId, repoName, ...params }) => { 79 | const result = resolveIdOrKey( 80 | 'project', 81 | { id: projectId, key: projectKey }, 82 | t 83 | ); 84 | if (!result.ok) { 85 | throw result.error; 86 | } 87 | const repoResult = resolveIdOrName( 88 | 'repository', 89 | { id: repoId, name: repoName }, 90 | t 91 | ); 92 | if (!repoResult.ok) { 93 | throw repoResult.error; 94 | } 95 | return backlog.getPullRequests( 96 | result.value, 97 | String(repoResult.value), 98 | params 99 | ); 100 | }, 101 | }; 102 | }; 103 | ``` -------------------------------------------------------------------------------- /src/tools/getIssue.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getIssueTool } from './getIssue.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('getIssueTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | getIssue: jest.fn<() => Promise<any>>().mockResolvedValue({ 9 | id: 1, 10 | projectId: 100, 11 | issueKey: 'TEST-1', 12 | keyId: 1, 13 | issueType: { 14 | id: 2, 15 | projectId: 100, 16 | name: 'Bug', 17 | color: '#990000', 18 | displayOrder: 0, 19 | }, 20 | summary: 'Test Issue', 21 | description: 'This is a test issue', 22 | priority: { 23 | id: 3, 24 | name: 'Normal', 25 | }, 26 | status: { 27 | id: 1, 28 | name: 'Open', 29 | projectId: 100, 30 | color: '#ff0000', 31 | displayOrder: 0, 32 | }, 33 | assignee: { 34 | id: 5, 35 | userId: 'user', 36 | name: 'Test User', 37 | roleType: 1, 38 | lang: 'en', 39 | mailAddress: '[email protected]', 40 | lastLoginTime: '2023-01-01T00:00:00Z', 41 | }, 42 | startDate: '2023-01-01', 43 | dueDate: '2023-01-31', 44 | estimatedHours: 10, 45 | actualHours: 5, 46 | createdUser: { 47 | id: 1, 48 | userId: 'admin', 49 | name: 'Admin User', 50 | roleType: 1, 51 | lang: 'en', 52 | mailAddress: '[email protected]', 53 | lastLoginTime: '2023-01-01T00:00:00Z', 54 | }, 55 | created: '2023-01-01T00:00:00Z', 56 | updatedUser: { 57 | id: 1, 58 | userId: 'admin', 59 | name: 'Admin User', 60 | roleType: 1, 61 | lang: 'en', 62 | mailAddress: '[email protected]', 63 | lastLoginTime: '2023-01-01T00:00:00Z', 64 | }, 65 | updated: '2023-01-01T00:00:00Z', 66 | }), 67 | }; 68 | 69 | const mockTranslationHelper = createTranslationHelper(); 70 | const tool = getIssueTool(mockBacklog as Backlog, mockTranslationHelper); 71 | 72 | it('returns issue information as formatted JSON text', async () => { 73 | const result = await tool.handler({ 74 | issueKey: 'TEST-1', 75 | }); 76 | 77 | if (Array.isArray(result)) { 78 | throw new Error('Unexpected array result'); 79 | } 80 | expect(result.summary).toEqual('Test Issue'); 81 | expect(result.description).toEqual('This is a test issue'); 82 | }); 83 | 84 | it('calls backlog.getIssue with correct params when using issue key', async () => { 85 | await tool.handler({ 86 | issueKey: 'TEST-1', 87 | }); 88 | 89 | expect(mockBacklog.getIssue).toHaveBeenCalledWith('TEST-1'); 90 | }); 91 | 92 | it('calls backlog.getIssue with correct params when using issue ID', async () => { 93 | await tool.handler({ 94 | issueId: 1, 95 | }); 96 | 97 | expect(mockBacklog.getIssue).toHaveBeenCalledWith(1); // Expect number 98 | }); 99 | 100 | it('throws an error if neither issueId nor issueKey is provided', async () => { 101 | await expect(tool.handler({})).rejects.toThrow(Error); 102 | }); 103 | }); 104 | ``` -------------------------------------------------------------------------------- /src/tools/addIssueComment.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { addIssueCommentTool } from './addIssueComment.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('addIssueCommentTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | postIssueComments: jest.fn<() => Promise<any>>().mockResolvedValue({ 9 | id: 3, 10 | content: 'This is a new comment', 11 | changeLog: [], 12 | createdUser: { 13 | id: 1, 14 | userId: 'admin', 15 | name: 'Admin User', 16 | roleType: 1, 17 | lang: 'en', 18 | mailAddress: '[email protected]', 19 | lastLoginTime: '2023-01-01T00:00:00Z', 20 | }, 21 | created: '2023-01-03T00:00:00Z', 22 | updated: '2023-01-03T00:00:00Z', 23 | }), 24 | }; 25 | 26 | const mockTranslationHelper = createTranslationHelper(); 27 | const tool = addIssueCommentTool( 28 | mockBacklog as Backlog, 29 | mockTranslationHelper 30 | ); 31 | 32 | it('returns created comment as formatted JSON text', async () => { 33 | const result = await tool.handler({ 34 | issueKey: 'TEST-1', 35 | content: 'This is a new comment', 36 | }); 37 | 38 | if (Array.isArray(result)) { 39 | throw new Error('Unexpected array result'); 40 | } 41 | 42 | expect(result.content).toContain('This is a new comment'); 43 | }); 44 | 45 | it('calls backlog.postIssueComments with correct params when using issue key', async () => { 46 | await tool.handler({ 47 | issueKey: 'TEST-1', 48 | content: 'This is a new comment', 49 | }); 50 | 51 | expect(mockBacklog.postIssueComments).toHaveBeenCalledWith('TEST-1', { 52 | content: 'This is a new comment', 53 | notifiedUserId: undefined, 54 | attachmentId: undefined, 55 | }); 56 | }); 57 | 58 | it('calls backlog.postIssueComments with correct params when using issue ID and notifications', async () => { 59 | await tool.handler({ 60 | issueId: 1, 61 | content: 'This is a new comment with notifications', 62 | notifiedUserId: [2, 3], 63 | }); 64 | 65 | expect(mockBacklog.postIssueComments).toHaveBeenCalledWith(1, { 66 | // Expect number 67 | content: 'This is a new comment with notifications', 68 | notifiedUserId: [2, 3], 69 | attachmentId: undefined, 70 | }); 71 | }); 72 | 73 | it('calls backlog.postIssueComments with correct params when using attachments', async () => { 74 | await tool.handler({ 75 | issueKey: 'TEST-1', 76 | content: 'This is a new comment with attachments', 77 | attachmentId: [1, 2], 78 | }); 79 | 80 | expect(mockBacklog.postIssueComments).toHaveBeenCalledWith('TEST-1', { 81 | content: 'This is a new comment with attachments', 82 | notifiedUserId: undefined, 83 | attachmentId: [1, 2], 84 | }); 85 | }); 86 | 87 | it('throws an error if neither issueId nor issueKey is provided', async () => { 88 | await expect( 89 | tool.handler({ 90 | content: 'This should fail due to missing issue identifier', 91 | }) 92 | ).rejects.toThrow(Error); 93 | }); 94 | }); 95 | ``` -------------------------------------------------------------------------------- /src/tools/addPullRequest.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { Backlog } from 'backlog-js'; 3 | import { buildToolSchema, ToolDefinition } from '../types/tool.js'; 4 | import { TranslationHelper } from '../createTranslationHelper.js'; 5 | import { PullRequestSchema } from '../types/zod/backlogOutputDefinition.js'; 6 | import { resolveIdOrKey, resolveIdOrName } from '../utils/resolveIdOrKey.js'; 7 | 8 | const addPullRequestSchema = buildToolSchema((t) => ({ 9 | projectId: z 10 | .number() 11 | .optional() 12 | .describe( 13 | t( 14 | 'TOOL_ADD_PULL_REQUEST_PROJECT_ID', 15 | 'The numeric ID of the project (e.g., 12345)' 16 | ) 17 | ), 18 | projectKey: z 19 | .string() 20 | .optional() 21 | .describe( 22 | t( 23 | 'TOOL_ADD_PULL_REQUEST_PROJECT_KEY', 24 | "The key of the project (e.g., 'PROJECT')" 25 | ) 26 | ), 27 | repoId: z 28 | .number() 29 | .optional() 30 | .describe(t('TOOL_ADD_PULL_REQUEST_REPO_ID', 'Repository ID')), 31 | repoName: z 32 | .string() 33 | .optional() 34 | .describe(t('TOOL_ADD_PULL_REQUEST_REPO_NAME', 'Repository name')), 35 | summary: z 36 | .string() 37 | .describe( 38 | t('TOOL_ADD_PULL_REQUEST_SUMMARY', 'Summary of the pull request') 39 | ), 40 | description: z 41 | .string() 42 | .describe( 43 | t('TOOL_ADD_PULL_REQUEST_DESCRIPTION', 'Description of the pull request') 44 | ), 45 | base: z 46 | .string() 47 | .describe(t('TOOL_ADD_PULL_REQUEST_BASE', 'Base branch name')), 48 | branch: z 49 | .string() 50 | .describe(t('TOOL_ADD_PULL_REQUEST_BRANCH', 'Branch name to merge')), 51 | issueId: z 52 | .number() 53 | .optional() 54 | .describe(t('TOOL_ADD_PULL_REQUEST_ISSUE_ID', 'Issue ID to link')), 55 | assigneeId: z 56 | .number() 57 | .optional() 58 | .describe( 59 | t('TOOL_ADD_PULL_REQUEST_ASSIGNEE_ID', 'User ID of the assignee') 60 | ), 61 | notifiedUserId: z 62 | .array(z.number()) 63 | .optional() 64 | .describe( 65 | t('TOOL_ADD_PULL_REQUEST_NOTIFIED_USER_ID', 'User IDs to notify') 66 | ), 67 | })); 68 | 69 | export const addPullRequestTool = ( 70 | backlog: Backlog, 71 | { t }: TranslationHelper 72 | ): ToolDefinition< 73 | ReturnType<typeof addPullRequestSchema>, 74 | (typeof PullRequestSchema)['shape'] 75 | > => { 76 | return { 77 | name: 'add_pull_request', 78 | description: t( 79 | 'TOOL_ADD_PULL_REQUEST_DESCRIPTION', 80 | 'Creates a new pull request' 81 | ), 82 | schema: z.object(addPullRequestSchema(t)), 83 | outputSchema: PullRequestSchema, 84 | handler: async ({ projectId, projectKey, repoId, repoName, ...params }) => { 85 | const result = resolveIdOrKey( 86 | 'project', 87 | { id: projectId, key: projectKey }, 88 | t 89 | ); 90 | if (!result.ok) { 91 | throw result.error; 92 | } 93 | const repoRes = resolveIdOrName( 94 | 'repository', 95 | { id: repoId, name: repoName }, 96 | t 97 | ); 98 | if (!repoRes.ok) { 99 | throw repoRes.error; 100 | } 101 | return backlog.postPullRequest( 102 | result.value, 103 | String(repoRes.value), 104 | params 105 | ); 106 | }, 107 | }; 108 | }; 109 | ``` -------------------------------------------------------------------------------- /src/tools/getPullRequestComments.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { Backlog } from 'backlog-js'; 3 | import { buildToolSchema, ToolDefinition } from '../types/tool.js'; 4 | import { TranslationHelper } from '../createTranslationHelper.js'; 5 | import { PullRequestCommentSchema } from '../types/zod/backlogOutputDefinition.js'; 6 | import { resolveIdOrKey, resolveIdOrName } from '../utils/resolveIdOrKey.js'; 7 | 8 | const getPullRequestCommentsSchema = buildToolSchema((t) => ({ 9 | projectId: z 10 | .number() 11 | .optional() 12 | .describe( 13 | t( 14 | 'TOOL_GET_PROJECT_PROJECT_ID', 15 | 'The numeric ID of the project (e.g., 12345)' 16 | ) 17 | ), 18 | projectKey: z 19 | .string() 20 | .optional() 21 | .describe( 22 | t( 23 | 'TOOL_GET_PROJECT_PROJECT_KEY', 24 | "The key of the project (e.g., 'PROJECT')" 25 | ) 26 | ), 27 | repoId: z 28 | .number() 29 | .optional() 30 | .describe( 31 | t('TOOL_GET_PULL_REQUEST_COMMENTS_REPO_ID_OR_NAME', 'Repository ID') 32 | ), 33 | repoName: z 34 | .string() 35 | .optional() 36 | .describe( 37 | t('TOOL_GET_PULL_REQUEST_COMMENTS_REPO_ID_OR_NAME', 'Repository name') 38 | ), 39 | number: z 40 | .number() 41 | .describe( 42 | t('TOOL_GET_PULL_REQUEST_COMMENTS_NUMBER', 'Pull request number') 43 | ), 44 | minId: z 45 | .number() 46 | .optional() 47 | .describe(t('TOOL_GET_PULL_REQUEST_COMMENTS_MIN_ID', 'Minimum comment ID')), 48 | maxId: z 49 | .number() 50 | .optional() 51 | .describe(t('TOOL_GET_PULL_REQUEST_COMMENTS_MAX_ID', 'Maximum comment ID')), 52 | count: z 53 | .number() 54 | .optional() 55 | .describe( 56 | t( 57 | 'TOOL_GET_PULL_REQUEST_COMMENTS_COUNT', 58 | 'Number of comments to retrieve' 59 | ) 60 | ), 61 | order: z 62 | .enum(['asc', 'desc']) 63 | .optional() 64 | .describe(t('TOOL_GET_PULL_REQUEST_COMMENTS_ORDER', 'Sort order')), 65 | })); 66 | 67 | export const getPullRequestCommentsTool = ( 68 | backlog: Backlog, 69 | { t }: TranslationHelper 70 | ): ToolDefinition< 71 | ReturnType<typeof getPullRequestCommentsSchema>, 72 | (typeof PullRequestCommentSchema)['shape'] 73 | > => { 74 | return { 75 | name: 'get_pull_request_comments', 76 | description: t( 77 | 'TOOL_GET_PULL_REQUEST_COMMENTS_DESCRIPTION', 78 | 'Returns list of comments for a pull request' 79 | ), 80 | schema: z.object(getPullRequestCommentsSchema(t)), 81 | outputSchema: PullRequestCommentSchema, 82 | handler: async ({ 83 | projectId, 84 | projectKey, 85 | repoId, 86 | repoName, 87 | number, 88 | ...params 89 | }) => { 90 | const result = resolveIdOrKey( 91 | 'project', 92 | { id: projectId, key: projectKey }, 93 | t 94 | ); 95 | if (!result.ok) { 96 | throw result.error; 97 | } 98 | const repoResult = resolveIdOrName( 99 | 'repository', 100 | { id: repoId, name: repoName }, 101 | t 102 | ); 103 | if (!repoResult.ok) { 104 | throw repoResult.error; 105 | } 106 | return backlog.getPullRequestComments( 107 | result.value, 108 | String(repoResult.value), 109 | number, 110 | params 111 | ); 112 | }, 113 | }; 114 | }; 115 | ``` -------------------------------------------------------------------------------- /src/tools/getGitRepositories.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getGitRepositoriesTool } from './getGitRepositories.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('getGitRepositoriesTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | getGitRepositories: jest.fn<() => Promise<any>>().mockResolvedValue([ 9 | { 10 | id: 1, 11 | projectId: 100, 12 | name: 'test-repo', 13 | description: 'Test repository', 14 | hookUrl: 'https://example.com/hooks/test-repo', 15 | httpUrl: 'https://example.com/git/test-repo.git', 16 | sshUrl: '[email protected]:test-repo.git', 17 | displayOrder: 0, 18 | pushedAt: '2023-01-01T00:00:00Z', 19 | createdUser: { 20 | id: 1, 21 | userId: 'user1', 22 | name: 'User One', 23 | }, 24 | created: '2023-01-01T00:00:00Z', 25 | updatedUser: { 26 | id: 1, 27 | userId: 'user1', 28 | name: 'User One', 29 | }, 30 | updated: '2023-01-01T00:00:00Z', 31 | }, 32 | { 33 | id: 2, 34 | projectId: 100, 35 | name: 'another-repo', 36 | description: 'Another repository', 37 | hookUrl: 'https://example.com/hooks/another-repo', 38 | httpUrl: 'https://example.com/git/another-repo.git', 39 | sshUrl: '[email protected]:another-repo.git', 40 | displayOrder: 1, 41 | pushedAt: '2023-01-02T00:00:00Z', 42 | createdUser: { 43 | id: 1, 44 | userId: 'user1', 45 | name: 'User One', 46 | }, 47 | created: '2023-01-02T00:00:00Z', 48 | updatedUser: { 49 | id: 1, 50 | userId: 'user1', 51 | name: 'User One', 52 | }, 53 | updated: '2023-01-02T00:00:00Z', 54 | }, 55 | ]), 56 | }; 57 | 58 | const mockTranslationHelper = createTranslationHelper(); 59 | const tool = getGitRepositoriesTool( 60 | mockBacklog as Backlog, 61 | mockTranslationHelper 62 | ); 63 | 64 | it('returns git repositories list as formatted JSON text', async () => { 65 | const result = await tool.handler({ 66 | projectKey: 'TEST', 67 | }); 68 | 69 | if (!Array.isArray(result)) { 70 | throw new Error('Unexpected non array result'); 71 | } 72 | expect(result[0].name).toEqual('test-repo'); 73 | expect(result[1].name).toEqual('another-repo'); 74 | }); 75 | 76 | it('calls backlog.getGitRepositories with correct params when using project key', async () => { 77 | await tool.handler({ 78 | projectKey: 'TEST', 79 | }); 80 | 81 | expect(mockBacklog.getGitRepositories).toHaveBeenCalledWith('TEST'); 82 | }); 83 | 84 | it('calls backlog.getGitRepositories with correct params when using project ID', async () => { 85 | await tool.handler({ 86 | projectId: 100, 87 | }); 88 | 89 | expect(mockBacklog.getGitRepositories).toHaveBeenCalledWith(100); 90 | }); 91 | 92 | it('throws an error if neither projectId nor projectKey is provided', async () => { 93 | const params = {}; // No identifier provided 94 | 95 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 96 | }); 97 | }); 98 | ``` -------------------------------------------------------------------------------- /src/tools/getGitRepository.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getGitRepositoryTool } from './getGitRepository.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('getGitRepositoryTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | getGitRepository: jest.fn<() => Promise<any>>().mockResolvedValue({ 9 | id: 1, 10 | projectId: 100, 11 | name: 'test-repo', 12 | description: 'Test repository', 13 | hookUrl: 'https://example.com/hooks/test-repo', 14 | httpUrl: 'https://example.com/git/test-repo.git', 15 | sshUrl: '[email protected]:test-repo.git', 16 | displayOrder: 0, 17 | pushedAt: '2023-01-01T00:00:00Z', 18 | createdUser: { 19 | id: 1, 20 | userId: 'user1', 21 | name: 'User One', 22 | }, 23 | created: '2023-01-01T00:00:00Z', 24 | updatedUser: { 25 | id: 1, 26 | userId: 'user1', 27 | name: 'User One', 28 | }, 29 | updated: '2023-01-01T00:00:00Z', 30 | }), 31 | }; 32 | 33 | const mockTranslationHelper = createTranslationHelper(); 34 | const tool = getGitRepositoryTool( 35 | mockBacklog as Backlog, 36 | mockTranslationHelper 37 | ); 38 | 39 | it('returns git repository information as formatted JSON text', async () => { 40 | const result = await tool.handler({ 41 | projectKey: 'TEST', 42 | repoName: 'test-repo', // Changed 43 | }); 44 | 45 | if (Array.isArray(result)) { 46 | throw new Error('Unexpected array result'); 47 | } 48 | 49 | expect(result.name).toContain('test-repo'); 50 | expect(result.description).toContain('Test repository'); 51 | }); 52 | 53 | it('calls backlog.getGitRepository with correct params when using project key and repoName', async () => { 54 | await tool.handler({ 55 | projectKey: 'TEST', 56 | repoName: 'test-repo', // Changed 57 | }); 58 | 59 | expect(mockBacklog.getGitRepository).toHaveBeenCalledWith( 60 | 'TEST', 61 | 'test-repo' 62 | ); 63 | }); 64 | 65 | it('calls backlog.getGitRepository with correct params when using projectId and repoName', async () => { 66 | await tool.handler({ 67 | projectId: 100, 68 | repoName: 'test-repo', // Changed 69 | }); 70 | 71 | expect(mockBacklog.getGitRepository).toHaveBeenCalledWith(100, 'test-repo'); 72 | }); 73 | 74 | it('calls backlog.getGitRepository with correct params when using projectId and repoId', async () => { 75 | await tool.handler({ 76 | projectId: 100, 77 | repoId: 200, // Added repoId 78 | }); 79 | 80 | expect(mockBacklog.getGitRepository).toHaveBeenCalledWith(100, '200'); 81 | }); 82 | 83 | it('throws an error if neither projectId nor projectKey is provided', async () => { 84 | const params = { 85 | // projectId and projectKey are missing 86 | repoName: 'test-repo', // Changed 87 | }; 88 | 89 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 90 | }); 91 | 92 | it('throws an error if neither repoId nor repoName is provided', async () => { 93 | const params = { 94 | projectKey: 'TEST', 95 | // repoId and repoName are missing 96 | }; 97 | 98 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 99 | }); 100 | }); 101 | ``` -------------------------------------------------------------------------------- /src/tools/updatePullRequest.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { Backlog } from 'backlog-js'; 3 | import { buildToolSchema, ToolDefinition } from '../types/tool.js'; 4 | import { TranslationHelper } from '../createTranslationHelper.js'; 5 | import { PullRequestSchema } from '../types/zod/backlogOutputDefinition.js'; 6 | import { resolveIdOrKey } from '../utils/resolveIdOrKey.js'; 7 | 8 | const updatePullRequestSchema = buildToolSchema((t) => ({ 9 | projectId: z 10 | .number() 11 | .optional() 12 | .describe( 13 | t( 14 | 'TOOL_UPDATE_PULL_REQUEST_PROJECT_ID', 15 | 'The numeric ID of the project (e.g., 12345)' 16 | ) 17 | ), 18 | projectKey: z 19 | .string() 20 | .optional() 21 | .describe( 22 | t( 23 | 'TOOL_UPDATE_PULL_REQUEST_PROJECT_KEY', 24 | "The key of the project (e.g., 'PROJECT')" 25 | ) 26 | ), 27 | repoId: z 28 | .number() 29 | .optional() 30 | .describe(t('TOOL_UPDATE_PULL_REQUEST_REPO_ID', 'Repository ID')), 31 | repoName: z 32 | .string() 33 | .optional() 34 | .describe(t('TOOL_UPDATE_PULL_REQUEST_REPO_NAME', 'Repository name')), 35 | number: z 36 | .number() 37 | .describe(t('TOOL_UPDATE_PULL_REQUEST_NUMBER', 'Pull request number')), 38 | summary: z 39 | .string() 40 | .optional() 41 | .describe( 42 | t('TOOL_UPDATE_PULL_REQUEST_SUMMARY', 'Summary of the pull request') 43 | ), 44 | description: z 45 | .string() 46 | .optional() 47 | .describe( 48 | t( 49 | 'TOOL_UPDATE_PULL_REQUEST_DESCRIPTION', 50 | 'Description of the pull request' 51 | ) 52 | ), 53 | issueId: z 54 | .number() 55 | .optional() 56 | .describe(t('TOOL_UPDATE_PULL_REQUEST_ISSUE_ID', 'Issue ID to link')), 57 | assigneeId: z 58 | .number() 59 | .optional() 60 | .describe( 61 | t('TOOL_UPDATE_PULL_REQUEST_ASSIGNEE_ID', 'User ID of the assignee') 62 | ), 63 | notifiedUserId: z 64 | .array(z.number()) 65 | .optional() 66 | .describe( 67 | t('TOOL_UPDATE_PULL_REQUEST_NOTIFIED_USER_ID', 'User IDs to notify') 68 | ), 69 | statusId: z 70 | .number() 71 | .optional() 72 | .describe(t('TOOL_UPDATE_PULL_REQUEST_STATUS_ID', 'Status ID')), 73 | })); 74 | 75 | export const updatePullRequestTool = ( 76 | backlog: Backlog, 77 | { t }: TranslationHelper 78 | ): ToolDefinition< 79 | ReturnType<typeof updatePullRequestSchema>, 80 | (typeof PullRequestSchema)['shape'] 81 | > => { 82 | return { 83 | name: 'update_pull_request', 84 | description: t( 85 | 'TOOL_UPDATE_PULL_REQUEST_DESCRIPTION', 86 | 'Updates an existing pull request' 87 | ), 88 | schema: z.object(updatePullRequestSchema(t)), 89 | outputSchema: PullRequestSchema, 90 | handler: async ({ 91 | projectId, 92 | projectKey, 93 | repoId, 94 | repoName, 95 | number, 96 | ...params 97 | }) => { 98 | const result = resolveIdOrKey( 99 | 'project', 100 | { id: projectId, key: projectKey }, 101 | t 102 | ); 103 | if (!result.ok) { 104 | throw result.error; 105 | } 106 | const resultRepo = resolveIdOrKey( 107 | 'repository', 108 | { id: repoId, key: repoName }, 109 | t 110 | ); 111 | if (!resultRepo.ok) { 112 | throw resultRepo.error; 113 | } 114 | return backlog.patchPullRequest( 115 | result.value, 116 | String(resultRepo.value), 117 | number, 118 | params 119 | ); 120 | }, 121 | }; 122 | }; 123 | ``` -------------------------------------------------------------------------------- /src/tools/dynamicTools/toolsets.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, expect, jest, it } from '@jest/globals'; 2 | import { z } from 'zod'; 3 | import { ToolDefinition, ToolRegistrar } from '../../types/tool.js'; 4 | import { ToolsetGroup } from '../../types/toolsets.js'; 5 | import { 6 | enableToolsetTool, 7 | getToolsetTools, 8 | listAvailableToolsets, 9 | } from './toolsets.js'; 10 | 11 | describe('dynamicTools', () => { 12 | const mockT = (key: string, fallback: string) => fallback; 13 | 14 | const mockTranslationHelper = { 15 | t: mockT, 16 | dump: () => ({}), 17 | }; 18 | 19 | const mockToolRegistrar: ToolRegistrar = { 20 | enableToolsetAndRefresh: jest 21 | .fn<() => Promise<string>>() 22 | .mockResolvedValue('Toolset enabled.'), 23 | }; 24 | const dummyTool: ToolDefinition<any, any> = { 25 | name: 'get_project_list', 26 | description: 'Returns a list of projects', 27 | schema: z.object({}), 28 | outputSchema: z.object({}), 29 | handler: async () => ({ 30 | content: [{ type: 'text', text: 'dummy' }], 31 | }), 32 | }; 33 | 34 | const mockToolsetGroup: ToolsetGroup = { 35 | toolsets: [ 36 | { 37 | name: 'project', 38 | description: 'Project management tools', 39 | enabled: false, 40 | tools: [dummyTool], 41 | }, 42 | ], 43 | }; 44 | 45 | it('enableToolsetTool - returns message after enabling toolset', async () => { 46 | const tool = enableToolsetTool(mockToolRegistrar, mockTranslationHelper); 47 | const schema = tool.schema; 48 | 49 | const validInput = schema.parse({ toolset: 'project' }); 50 | 51 | const result = await tool.handler(validInput); 52 | expect(result).toEqual({ 53 | content: [ 54 | { 55 | type: 'text', 56 | text: 'Toolset enabled.', 57 | }, 58 | ], 59 | }); 60 | 61 | expect(mockToolRegistrar.enableToolsetAndRefresh).toHaveBeenCalledWith( 62 | 'project' 63 | ); 64 | }); 65 | 66 | it('listAvailableToolsets - returns list of toolsets', async () => { 67 | const tool = listAvailableToolsets(mockTranslationHelper, mockToolsetGroup); 68 | 69 | const result = await tool.handler({}); 70 | const json = JSON.parse(result.content[0].text as string); 71 | 72 | expect(Array.isArray(json)).toBe(true); 73 | expect(json[0]).toEqual({ 74 | name: 'project', 75 | description: 'Project management tools', 76 | currentlyEnabled: false, 77 | canEnable: true, 78 | }); 79 | }); 80 | 81 | it('getToolsetTools - returns tools of a specific toolset', async () => { 82 | const tool = getToolsetTools(mockTranslationHelper, mockToolsetGroup); 83 | const schema = tool.schema; 84 | 85 | const input = schema.parse({ toolset: 'project' }); 86 | const result = await tool.handler(input); 87 | const json = JSON.parse(result.content[0].text); 88 | 89 | expect(Array.isArray(json)).toBe(true); 90 | expect(json[0]).toEqual({ 91 | name: 'get_project_list', 92 | description: 'Returns a list of projects', 93 | toolset: 'project', 94 | canEnable: true, 95 | }); 96 | }); 97 | 98 | it('getToolsetTools - returns error if toolset not found', async () => { 99 | const tool = getToolsetTools(mockTranslationHelper, mockToolsetGroup); 100 | const result = await tool.handler({ toolset: 'nonexistent' }); 101 | 102 | expect(result).toEqual({ 103 | content: [ 104 | { 105 | type: 'text', 106 | text: "Toolset 'nonexistent' not found.", 107 | }, 108 | ], 109 | }); 110 | }); 111 | }); 112 | ``` -------------------------------------------------------------------------------- /src/tools/updateVersionMilestone.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { updateVersionMilestoneTool } from './updateVersionMilestone.js'; 2 | import { jest, describe, expect, it } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('updateVersionMilestoneTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | patchVersions: jest.fn<() => Promise<any>>().mockResolvedValue({ 9 | id: 1, 10 | projectId: 100, 11 | name: 'Updated Version', 12 | description: 'Updated version description', 13 | startDate: '2023-01-01T00:00:00Z', 14 | releaseDueDate: '2023-12-31T00:00:00Z', 15 | archived: false, 16 | }), 17 | }; 18 | 19 | const mockTranslationHelper = createTranslationHelper(); 20 | const tool = updateVersionMilestoneTool( 21 | mockBacklog as Backlog, 22 | mockTranslationHelper 23 | ); 24 | 25 | it('returns updated version milestone', async () => { 26 | const result = await tool.handler({ 27 | projectKey: 'TEST', 28 | projectId: 100, 29 | id: 1, 30 | name: 'Updated Version', 31 | description: 'Updated version description', 32 | startDate: '2023-01-01T00:00:00Z', 33 | releaseDueDate: '2023-12-31T00:00:00Z', 34 | archived: false, 35 | }); 36 | 37 | if (Array.isArray(result)) { 38 | throw new Error('Unexpected array result'); 39 | } 40 | 41 | expect(result.name).toEqual('Updated Version'); 42 | expect(result.description).toEqual('Updated version description'); 43 | expect(result.startDate).toEqual('2023-01-01T00:00:00Z'); 44 | expect(result.releaseDueDate).toEqual('2023-12-31T00:00:00Z'); 45 | expect(result.archived).toBe(false); 46 | }); 47 | 48 | it('calls backlog.patchVersions with correct params when using projectKey', async () => { 49 | const params = { 50 | projectKey: 'TEST', 51 | id: 1, 52 | name: 'Updated Version', 53 | description: 'Updated version description', 54 | startDate: '2023-01-01T00:00:00Z', 55 | releaseDueDate: '2023-12-31T00:00:00Z', 56 | archived: false, 57 | }; 58 | 59 | await tool.handler(params); 60 | 61 | expect(mockBacklog.patchVersions).toHaveBeenCalledWith('TEST', 1, { 62 | name: 'Updated Version', 63 | description: 'Updated version description', 64 | startDate: '2023-01-01T00:00:00Z', 65 | releaseDueDate: '2023-12-31T00:00:00Z', 66 | archived: false, 67 | }); 68 | }); 69 | 70 | it('calls backlog.pathVersions with correct params when using projectId', async () => { 71 | const params = { 72 | projectId: 100, 73 | id: 1, 74 | name: 'Updated Version', 75 | description: 'Updated version description', 76 | startDate: '2023-01-01T00:00:00Z', 77 | releaseDueDate: '2023-12-31T00:00:00Z', 78 | archived: false, 79 | }; 80 | 81 | await tool.handler(params); 82 | 83 | expect(mockBacklog.patchVersions).toHaveBeenCalledWith(100, 1, { 84 | name: 'Updated Version', 85 | description: 'Updated version description', 86 | startDate: '2023-01-01T00:00:00Z', 87 | releaseDueDate: '2023-12-31T00:00:00Z', 88 | archived: false, 89 | }); 90 | }); 91 | 92 | it('throws an error if neither projectId nor projectKey is provided', async () => { 93 | const params = { 94 | // projectId and projectKey are missing 95 | id: 1, 96 | name: 'Version without project', 97 | description: 'This should fail', 98 | }; 99 | 100 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 101 | }); 102 | }); 103 | ``` -------------------------------------------------------------------------------- /src/tools/addVersionMilestone.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { addVersionMilestoneTool } from './addVersionMilestone.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('addVersionMilestoneTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | postVersions: jest.fn<() => Promise<any>>().mockResolvedValue({ 9 | id: 1, 10 | projectId: 100, 11 | name: 'Version 1.0.0', 12 | description: 'Initial release version', 13 | startDate: '2023-01-01T00:00:00Z', 14 | releaseDueDate: '2023-03-31T00:00:00Z', 15 | archived: false, 16 | displayOrder: 1, 17 | }), 18 | }; 19 | 20 | const mockTranslationHelper = createTranslationHelper(); 21 | const tool = addVersionMilestoneTool( 22 | mockBacklog as Backlog, 23 | mockTranslationHelper 24 | ); 25 | 26 | it('returns created version milestone as formatted JSON text', async () => { 27 | const result = await tool.handler({ 28 | projectKey: 'TEST', 29 | name: 'Version 1.0.0', 30 | description: 'Initial release version', 31 | startDate: '2023-01-01T00:00:00Z', 32 | releaseDueDate: '2023-03-31T00:00:00Z', 33 | }); 34 | 35 | if (Array.isArray(result)) { 36 | throw new Error('Unexpected array result'); 37 | } 38 | expect(result.name).toEqual('Version 1.0.0'); 39 | expect(result.description).toEqual('Initial release version'); 40 | expect(result.startDate).toEqual('2023-01-01T00:00:00Z'); 41 | expect(result.releaseDueDate).toEqual('2023-03-31T00:00:00Z'); 42 | }); 43 | 44 | it('calls backlog.postVersions with correct params when using projectKey', async () => { 45 | const params = { 46 | projectKey: 'TEST', 47 | name: 'Version 1.0.0', 48 | description: 'Initial release version', 49 | startDate: '2023-01-01T00:00:00Z', 50 | releaseDueDate: '2024-03-31T00:00:00Z', 51 | }; 52 | 53 | await tool.handler(params); 54 | 55 | expect(mockBacklog.postVersions).toHaveBeenCalledWith('TEST', { 56 | name: 'Version 1.0.0', 57 | description: 'Initial release version', 58 | startDate: '2023-01-01T00:00:00Z', 59 | releaseDueDate: '2024-03-31T00:00:00Z', 60 | }); 61 | }); 62 | 63 | it('calls backlog.postVersions with correct params when using projectId', async () => { 64 | const params = { 65 | projectId: 100, 66 | name: 'Version 2.0.0', 67 | description: 'Major release', 68 | startDate: '2023-04-01T00:00:00Z', 69 | releaseDueDate: '2023-06-30T00:00:00Z', 70 | }; 71 | 72 | await tool.handler(params); 73 | 74 | expect(mockBacklog.postVersions).toHaveBeenCalledWith(100, { 75 | name: 'Version 2.0.0', 76 | description: 'Major release', 77 | startDate: '2023-04-01T00:00:00Z', 78 | releaseDueDate: '2023-06-30T00:00:00Z', 79 | }); 80 | }); 81 | 82 | it('calls backlog.postVersions with minimal required params', async () => { 83 | const params = { 84 | projectKey: 'TEST', 85 | name: 'Quick Version', 86 | }; 87 | 88 | await tool.handler(params); 89 | 90 | expect(mockBacklog.postVersions).toHaveBeenCalledWith('TEST', { 91 | name: 'Quick Version', 92 | }); 93 | }); 94 | 95 | it('throws an error if neither projectId nor projectKey is provided', async () => { 96 | const params = { 97 | // projectId and projectKey are missing 98 | name: 'Version without project', 99 | description: 'This should fail', 100 | }; 101 | 102 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 103 | }); 104 | }); 105 | ``` -------------------------------------------------------------------------------- /src/tools/getWikiPages.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getWikiPagesTool } from './getWikiPages.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('getWikiPagesTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | getWikis: jest.fn<() => Promise<any>>().mockResolvedValue([ 9 | { 10 | id: 1, 11 | projectId: 100, 12 | name: 'Getting Started', 13 | tags: ['guide', 'tutorial'], 14 | createdUser: { 15 | id: 1, 16 | userId: 'admin', 17 | name: 'Admin User', 18 | roleType: 1, 19 | lang: 'en', 20 | mailAddress: '[email protected]', 21 | lastLoginTime: '2023-01-01T00:00:00Z', 22 | }, 23 | created: '2023-01-01T00:00:00Z', 24 | updatedUser: { 25 | id: 1, 26 | userId: 'admin', 27 | name: 'Admin User', 28 | roleType: 1, 29 | lang: 'en', 30 | mailAddress: '[email protected]', 31 | lastLoginTime: '2023-01-01T00:00:00Z', 32 | }, 33 | updated: '2023-01-01T00:00:00Z', 34 | }, 35 | { 36 | id: 2, 37 | projectId: 100, 38 | name: 'API Documentation', 39 | tags: ['api', 'reference'], 40 | createdUser: { 41 | id: 1, 42 | userId: 'admin', 43 | name: 'Admin User', 44 | roleType: 1, 45 | lang: 'en', 46 | mailAddress: '[email protected]', 47 | lastLoginTime: '2023-01-01T00:00:00Z', 48 | }, 49 | created: '2023-01-01T00:00:00Z', 50 | updatedUser: { 51 | id: 1, 52 | userId: 'admin', 53 | name: 'Admin User', 54 | roleType: 1, 55 | lang: 'en', 56 | mailAddress: '[email protected]', 57 | lastLoginTime: '2023-01-01T00:00:00Z', 58 | }, 59 | updated: '2023-01-01T00:00:00Z', 60 | }, 61 | ]), 62 | }; 63 | 64 | const mockTranslationHelper = createTranslationHelper(); 65 | const tool = getWikiPagesTool(mockBacklog as Backlog, mockTranslationHelper); 66 | 67 | it('returns wiki pages as formatted JSON text', async () => { 68 | const result = await tool.handler({ 69 | projectKey: 'TEST', 70 | }); 71 | 72 | if (!Array.isArray(result)) { 73 | throw new Error('Unexpected non array result'); 74 | } 75 | expect(result).toHaveLength(2); 76 | expect(result[0].name).toContain('Getting Started'); 77 | expect(result[1].name).toContain('API Documentation'); 78 | }); 79 | 80 | it('calls backlog.getWikis with correct params when using project key', async () => { 81 | await tool.handler({ 82 | projectKey: 'TEST', 83 | }); 84 | 85 | expect(mockBacklog.getWikis).toHaveBeenCalledWith({ 86 | projectIdOrKey: 'TEST', // This is correct as backlog-js expects projectIdOrKey 87 | keyword: undefined, 88 | }); 89 | }); 90 | 91 | it('calls backlog.getWikis with correct params when using project ID and keyword', async () => { 92 | await tool.handler({ 93 | projectId: 100, 94 | keyword: 'api', 95 | }); 96 | 97 | expect(mockBacklog.getWikis).toHaveBeenCalledWith({ 98 | projectIdOrKey: 100, // This is correct as backlog-js expects projectIdOrKey 99 | keyword: 'api', 100 | }); 101 | }); 102 | 103 | it('throws an error if neither projectId nor projectKey is provided', async () => { 104 | const params = { 105 | // projectId and projectKey are missing 106 | keyword: 'test', 107 | }; 108 | 109 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 110 | }); 111 | }); 112 | ``` -------------------------------------------------------------------------------- /src/tools/getPullRequest.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getPullRequestTool } from './getPullRequest.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('getPullRequestTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | getPullRequest: jest.fn<() => Promise<any>>().mockResolvedValue({ 9 | id: 1, 10 | projectId: 100, 11 | repositoryId: 200, 12 | number: 1, 13 | summary: 'Fix bug in login', 14 | description: 'This PR fixes a bug in the login process', 15 | base: 'main', 16 | branch: 'fix/login-bug', 17 | status: { 18 | id: 1, 19 | name: 'Open', 20 | }, 21 | assignee: { 22 | id: 1, 23 | userId: 'user1', 24 | name: 'User One', 25 | }, 26 | issue: { 27 | id: 1000, 28 | issueKey: 'TEST-1', 29 | summary: 'Login bug', 30 | }, 31 | baseCommit: 'abc123', 32 | branchCommit: 'def456', 33 | closeAt: null, 34 | mergeAt: null, 35 | createdUser: { 36 | id: 1, 37 | userId: 'user1', 38 | name: 'User One', 39 | }, 40 | created: '2023-01-01T00:00:00Z', 41 | updatedUser: { 42 | id: 1, 43 | userId: 'user1', 44 | name: 'User One', 45 | }, 46 | updated: '2023-01-01T00:00:00Z', 47 | }), 48 | }; 49 | 50 | const mockTranslationHelper = createTranslationHelper(); 51 | const tool = getPullRequestTool( 52 | mockBacklog as Backlog, 53 | mockTranslationHelper 54 | ); 55 | 56 | it('returns pull request information as formatted JSON text', async () => { 57 | const result = await tool.handler({ 58 | projectKey: 'TEST', 59 | repoName: 'test-repo', // Changed from repoIdOrName 60 | number: 1, 61 | }); 62 | 63 | if (Array.isArray(result)) { 64 | throw new Error('Unexpected array result'); 65 | } 66 | 67 | expect(result.summary).toContain('Fix bug in login'); 68 | expect(result.description).toContain( 69 | 'This PR fixes a bug in the login process' 70 | ); 71 | }); 72 | 73 | it('calls backlog.getPullRequest with correct params when using repoName', async () => { 74 | await tool.handler({ 75 | projectKey: 'TEST', 76 | repoName: 'test-repo', // Changed from repoIdOrName 77 | number: 1, 78 | }); 79 | 80 | expect(mockBacklog.getPullRequest).toHaveBeenCalledWith( 81 | 'TEST', 82 | 'test-repo', 83 | 1 84 | ); 85 | }); 86 | 87 | it('calls backlog.getPullRequest with correct params when using projectId and repoName', async () => { 88 | await tool.handler({ 89 | projectId: 100, 90 | repoName: 'test-repo', // Changed from repoIdOrName 91 | number: 1, 92 | }); 93 | 94 | expect(mockBacklog.getPullRequest).toHaveBeenCalledWith( 95 | 100, 96 | 'test-repo', 97 | 1 98 | ); 99 | }); 100 | 101 | it('calls backlog.getPullRequest with correct params when using projectId and repoId', async () => { 102 | await tool.handler({ 103 | projectId: 100, 104 | repoId: 200, // Added repoId 105 | number: 1, 106 | }); 107 | 108 | expect(mockBacklog.getPullRequest).toHaveBeenCalledWith(100, '200', 1); 109 | }); 110 | 111 | it('throws an error if neither projectId nor projectKey is provided', async () => { 112 | const params = { 113 | // projectId and projectKey are missing 114 | repoName: 'test-repo', 115 | number: 1, 116 | }; 117 | 118 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 119 | }); 120 | 121 | it('throws an error if neither repoId nor repoName is provided', async () => { 122 | const params = { 123 | projectKey: 'TEST', 124 | // repoId and repoName are missing 125 | number: 1, 126 | }; 127 | 128 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 129 | }); 130 | }); 131 | ``` -------------------------------------------------------------------------------- /src/tools/addPullRequestComment.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { addPullRequestCommentTool } from './addPullRequestComment.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('addPullRequestCommentTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | postPullRequestComments: jest.fn<() => Promise<any>>().mockResolvedValue({ 9 | id: 1, 10 | content: 'This looks good to me!', 11 | changeLog: [], 12 | createdUser: { 13 | id: 1, 14 | userId: 'user1', 15 | name: 'User One', 16 | }, 17 | created: '2023-01-01T00:00:00Z', 18 | updated: '2023-01-01T00:00:00Z', 19 | stars: [], 20 | notifications: [], 21 | }), 22 | }; 23 | 24 | const mockTranslationHelper = createTranslationHelper(); 25 | const tool = addPullRequestCommentTool( 26 | mockBacklog as Backlog, 27 | mockTranslationHelper 28 | ); 29 | 30 | it('returns created comment as formatted JSON text', async () => { 31 | const result = await tool.handler({ 32 | projectKey: 'TEST', 33 | repoName: 'test-repo', // Changed 34 | number: 1, 35 | content: 'This looks good to me!', 36 | }); 37 | 38 | if (Array.isArray(result)) { 39 | throw new Error('Unexpected array result'); 40 | } 41 | expect(result.content).toContain('This looks good to me!'); 42 | }); 43 | 44 | it('calls backlog.postPullRequestComments with correct params when using repoName', async () => { 45 | const params = { 46 | projectKey: 'TEST', 47 | repoName: 'test-repo', // Changed 48 | number: 1, 49 | content: 'This looks good to me!', 50 | notifiedUserId: [2, 3], 51 | }; 52 | 53 | await tool.handler(params); 54 | 55 | expect(mockBacklog.postPullRequestComments).toHaveBeenCalledWith( 56 | 'TEST', 57 | 'test-repo', 58 | 1, 59 | { 60 | content: 'This looks good to me!', 61 | notifiedUserId: [2, 3], 62 | } 63 | ); 64 | }); 65 | 66 | it('calls backlog.postPullRequestComments with correct params when using projectId and repoName', async () => { 67 | const params = { 68 | projectId: 100, 69 | repoName: 'test-repo', // Changed 70 | number: 1, 71 | content: 'Comment via projectId', 72 | }; 73 | 74 | await tool.handler(params); 75 | 76 | expect(mockBacklog.postPullRequestComments).toHaveBeenCalledWith( 77 | 100, 78 | 'test-repo', 79 | 1, 80 | { 81 | content: 'Comment via projectId', 82 | notifiedUserId: undefined, 83 | } 84 | ); 85 | }); 86 | 87 | it('calls backlog.postPullRequestComments with correct params when using projectId and repoId', async () => { 88 | const params = { 89 | projectId: 100, 90 | repoId: 200, // Added repoId 91 | number: 1, 92 | content: 'Comment via repoId', 93 | }; 94 | 95 | await tool.handler(params); 96 | 97 | expect(mockBacklog.postPullRequestComments).toHaveBeenCalledWith( 98 | 100, 99 | '200', 100 | 1, 101 | { 102 | content: 'Comment via repoId', 103 | notifiedUserId: undefined, 104 | } 105 | ); 106 | }); 107 | 108 | it('throws an error if neither projectId nor projectKey is provided', async () => { 109 | const params = { 110 | // projectId and projectKey are missing 111 | repoName: 'test-repo', // Changed 112 | number: 1, 113 | content: 'Test content', 114 | }; 115 | 116 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 117 | }); 118 | 119 | it('throws an error if neither repoId nor repoName is provided', async () => { 120 | const params = { 121 | projectKey: 'TEST', 122 | // repoId and repoName are missing 123 | number: 1, 124 | content: 'Test content', 125 | }; 126 | 127 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 128 | }); 129 | }); 130 | ``` -------------------------------------------------------------------------------- /src/handlers/builders/composeToolHandler.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, expect, it, jest } from '@jest/globals'; 2 | import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; 3 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 4 | import { z } from 'zod'; 5 | import { ErrorLike } from '../../types/result.js'; 6 | import { ToolDefinition } from '../../types/tool.js'; 7 | import { composeToolHandler } from './composeToolHandler.js'; 8 | 9 | const dummyErrorHandler = (err: unknown): ErrorLike => ({ 10 | kind: 'error', 11 | message: 'Handled: ' + (err as Error).message, 12 | }); 13 | 14 | const dummyExtra: RequestHandlerExtra = { 15 | signal: {} as unknown as any, 16 | }; 17 | 18 | describe('composeToolHandler', () => { 19 | const baseSchema = z.object({ 20 | name: z.string(), 21 | }); 22 | 23 | const outputSchema = z.object({ 24 | id: z.number(), 25 | name: z.string(), 26 | }); 27 | 28 | const tool: ToolDefinition<any, any> = { 29 | name: 'get_sample', 30 | description: 'Returns sample', 31 | schema: baseSchema, 32 | outputSchema, 33 | handler: async () => ({ id: 1, name: 'Sample' }), 34 | importantFields: ['id', 'name'], 35 | }; 36 | 37 | it("adds 'fields' when useFields is true", async () => { 38 | const composed = composeToolHandler(tool, { 39 | useFields: true, 40 | maxTokens: 500, 41 | }); 42 | 43 | expect(tool.schema.shape).toHaveProperty('fields'); 44 | 45 | const result = await composed({ id: 123, fields: '{ id }' }, dummyExtra); 46 | expect((result as CallToolResult).content[0].type).toBe('text'); 47 | expect((result as CallToolResult).content[0].text).toContain('id'); 48 | expect((result as CallToolResult).content[0].text).not.toContain('name'); 49 | }); 50 | 51 | it("does not add 'fields' when useFields is false", async () => { 52 | const toolWithoutFields: ToolDefinition<any, any> = { 53 | ...tool, 54 | schema: baseSchema, 55 | handler: jest.fn(async () => ({ 56 | kind: 'ok', 57 | data: { id: 456, name: 'hoge' }, 58 | })), 59 | }; 60 | 61 | const composed = composeToolHandler(toolWithoutFields, { 62 | useFields: false, 63 | maxTokens: 500, 64 | }); 65 | 66 | expect(toolWithoutFields.schema.shape).not.toHaveProperty('fields'); 67 | 68 | const result = await composed({ id: 456 }, dummyExtra); 69 | expect((result as CallToolResult).content[0].type).toBe('text'); 70 | expect((result as CallToolResult).content[0].text).toContain('id'); 71 | expect((result as CallToolResult).content[0].text).toContain('name'); 72 | }); 73 | 74 | it('extends schema and composes handler with field picking and token limit', async () => { 75 | const composed = composeToolHandler(tool, { 76 | useFields: true, 77 | errorHandler: dummyErrorHandler, 78 | maxTokens: 100, 79 | }); 80 | 81 | const input = { name: 'test', fields: '{ id name }' }; 82 | const result = await composed(input, {} as any); 83 | expect(result).toHaveProperty('content'); 84 | expect(result.content[0].type).toBe('text'); 85 | expect(result.content[0].text).toContain('"id": 1'); 86 | expect(result.content[0].text).toContain('"name": "Sample"'); 87 | }); 88 | 89 | it('handles error with provided errorHandler', async () => { 90 | const errorTool = { 91 | ...tool, 92 | handler: async () => { 93 | throw new Error('fail test'); 94 | }, 95 | }; 96 | 97 | const composed = composeToolHandler(errorTool, { 98 | useFields: true, 99 | errorHandler: dummyErrorHandler, 100 | maxTokens: 100, 101 | }); 102 | 103 | const input = { name: 'test', fields: '{ id name }' }; 104 | const result = await composed(input, {} as any); 105 | expect(result).toHaveProperty('isError', true); 106 | expect(result.content[0].text).toMatch(/Handled: fail test/); 107 | }); 108 | }); 109 | ``` -------------------------------------------------------------------------------- /src/tools/updatePullRequestComment.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { updatePullRequestCommentTool } from './updatePullRequestComment.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('updatePullRequestCommentTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | patchPullRequestComments: jest.fn<() => Promise<any>>().mockResolvedValue({ 9 | id: 1, 10 | content: 'Updated comment content', 11 | changeLog: [], 12 | createdUser: { 13 | id: 1, 14 | userId: 'user1', 15 | name: 'User One', 16 | }, 17 | created: '2023-01-01T00:00:00Z', 18 | updated: '2023-01-02T00:00:00Z', 19 | stars: [], 20 | notifications: [], 21 | }), 22 | }; 23 | 24 | const mockTranslationHelper = createTranslationHelper(); 25 | const tool = updatePullRequestCommentTool( 26 | mockBacklog as Backlog, 27 | mockTranslationHelper 28 | ); 29 | 30 | it('returns updated comment', async () => { 31 | const result = await tool.handler({ 32 | projectKey: 'TEST', 33 | repoName: 'test-repo', // Changed 34 | number: 1, 35 | commentId: 1, 36 | content: 'Updated comment content', 37 | }); 38 | 39 | expect(result).toHaveProperty('content', 'Updated comment content'); 40 | expect(result).toHaveProperty('id', 1); 41 | }); 42 | 43 | it('calls backlog.patchPullRequestComments with correct params when using repoName', async () => { 44 | const params = { 45 | projectKey: 'TEST', 46 | repoName: 'test-repo', // Changed 47 | number: 1, 48 | commentId: 1, 49 | content: 'Updated comment content', 50 | }; 51 | 52 | await tool.handler(params); 53 | 54 | expect(mockBacklog.patchPullRequestComments).toHaveBeenCalledWith( 55 | 'TEST', 56 | 'test-repo', 57 | 1, 58 | 1, 59 | { 60 | content: 'Updated comment content', 61 | } 62 | ); 63 | }); 64 | 65 | it('calls backlog.patchPullRequestComments with correct params when using projectId and repoName', async () => { 66 | const params = { 67 | projectId: 100, 68 | repoName: 'test-repo', // Changed 69 | number: 1, 70 | commentId: 1, 71 | content: 'Updated comment content via projectId', 72 | }; 73 | 74 | await tool.handler(params); 75 | 76 | expect(mockBacklog.patchPullRequestComments).toHaveBeenCalledWith( 77 | 100, 78 | 'test-repo', 79 | 1, 80 | 1, 81 | { 82 | content: 'Updated comment content via projectId', 83 | } 84 | ); 85 | }); 86 | 87 | it('calls backlog.patchPullRequestComments with correct params when using projectId and repoId', async () => { 88 | const params = { 89 | projectId: 100, 90 | repoId: 200, // Added repoId 91 | number: 1, 92 | commentId: 1, 93 | content: 'Updated comment content via repoId', 94 | }; 95 | 96 | await tool.handler(params); 97 | 98 | expect(mockBacklog.patchPullRequestComments).toHaveBeenCalledWith( 99 | 100, 100 | '200', 101 | 1, 102 | 1, 103 | { 104 | content: 'Updated comment content via repoId', 105 | } 106 | ); 107 | }); 108 | 109 | it('throws an error if neither projectId nor projectKey is provided', async () => { 110 | const params = { 111 | // projectId and projectKey are missing 112 | repoName: 'test-repo', // Changed 113 | number: 1, 114 | commentId: 1, 115 | content: 'Test content', 116 | }; 117 | 118 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 119 | }); 120 | 121 | it('throws an error if neither repoId nor repoName is provided', async () => { 122 | const params = { 123 | projectKey: 'TEST', 124 | // repoId and repoName are missing 125 | number: 1, 126 | commentId: 1, 127 | content: 'Test content', 128 | }; 129 | 130 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 131 | }); 132 | }); 133 | ``` -------------------------------------------------------------------------------- /src/tools/getPullRequestComments.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getPullRequestCommentsTool } from './getPullRequestComments.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('getPullRequestCommentsTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | getPullRequestComments: jest.fn<() => Promise<any>>().mockResolvedValue([ 9 | { 10 | id: 1, 11 | content: 'This looks good to me!', 12 | changeLog: [], 13 | createdUser: { 14 | id: 1, 15 | userId: 'user1', 16 | name: 'User One', 17 | }, 18 | created: '2023-01-01T00:00:00Z', 19 | updated: '2023-01-01T00:00:00Z', 20 | stars: [], 21 | notifications: [], 22 | }, 23 | { 24 | id: 2, 25 | content: 'I found a small issue in the code.', 26 | changeLog: [], 27 | createdUser: { 28 | id: 2, 29 | userId: 'user2', 30 | name: 'User Two', 31 | }, 32 | created: '2023-01-02T00:00:00Z', 33 | updated: '2023-01-02T00:00:00Z', 34 | stars: [], 35 | notifications: [], 36 | }, 37 | ]), 38 | }; 39 | 40 | const mockTranslationHelper = createTranslationHelper(); 41 | const tool = getPullRequestCommentsTool( 42 | mockBacklog as Backlog, 43 | mockTranslationHelper 44 | ); 45 | 46 | it('returns pull request comments', async () => { 47 | const result = await tool.handler({ 48 | projectKey: 'TEST', 49 | repoName: 'test-repo', // Changed 50 | number: 1, 51 | }); 52 | 53 | if (!Array.isArray(result)) { 54 | throw new Error('Unexpected non array result'); 55 | } 56 | expect(result).toHaveLength(2); 57 | expect(result[0]).toHaveProperty('content', 'This looks good to me!'); 58 | expect(result[1]).toHaveProperty( 59 | 'content', 60 | 'I found a small issue in the code.' 61 | ); 62 | }); 63 | 64 | it('calls backlog.getPullRequestComments with correct params when using repoName', async () => { 65 | const params = { 66 | projectKey: 'TEST', 67 | repoName: 'test-repo', // Changed 68 | number: 1, 69 | minId: 100, 70 | maxId: 200, 71 | count: 20, 72 | order: 'desc' as const, 73 | }; 74 | 75 | await tool.handler(params); 76 | 77 | expect(mockBacklog.getPullRequestComments).toHaveBeenCalledWith( 78 | 'TEST', 79 | 'test-repo', 80 | 1, 81 | { 82 | minId: 100, 83 | maxId: 200, 84 | count: 20, 85 | order: 'desc', 86 | } 87 | ); 88 | }); 89 | 90 | it('calls backlog.getPullRequestComments with correct params when using projectId and repoName', async () => { 91 | const params = { 92 | projectId: 100, 93 | repoName: 'test-repo', // Changed 94 | number: 1, 95 | }; 96 | 97 | await tool.handler(params); 98 | 99 | expect(mockBacklog.getPullRequestComments).toHaveBeenCalledWith( 100 | 100, 101 | 'test-repo', 102 | 1, 103 | {} 104 | ); 105 | }); 106 | 107 | it('calls backlog.getPullRequestComments with correct params when using projectId and repoId', async () => { 108 | const params = { 109 | projectId: 100, 110 | repoId: 200, // Added repoId 111 | number: 1, 112 | }; 113 | 114 | await tool.handler(params); 115 | 116 | expect(mockBacklog.getPullRequestComments).toHaveBeenCalledWith( 117 | 100, 118 | '200', 119 | 1, 120 | {} 121 | ); 122 | }); 123 | 124 | it('throws an error if neither projectId nor projectKey is provided', async () => { 125 | const params = { 126 | // projectId and projectKey are missing 127 | repoName: 'test-repo', 128 | number: 1, 129 | }; 130 | 131 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 132 | }); 133 | 134 | it('throws an error if neither repoId nor repoName is provided', async () => { 135 | const params = { 136 | projectKey: 'TEST', 137 | // repoId and repoName are missing 138 | number: 1, 139 | }; 140 | 141 | await expect(tool.handler(params as any)).rejects.toThrow(Error); 142 | }); 143 | }); 144 | ``` -------------------------------------------------------------------------------- /src/tools/dynamicTools/toolsets.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { 3 | buildToolSchema, 4 | DynamicToolDefinition, 5 | ToolRegistrar, 6 | } from '../../types/tool.js'; 7 | import { DynamicToolsetGroup, ToolsetGroup } from '../../types/toolsets.js'; 8 | import { TranslationHelper } from '../../createTranslationHelper.js'; 9 | 10 | export const dynamicTools = function ( 11 | toolRegistrar: ToolRegistrar, 12 | helper: TranslationHelper, 13 | toolsetGroup: ToolsetGroup 14 | ): DynamicToolsetGroup { 15 | return { 16 | toolsets: [ 17 | { 18 | name: 'dynamic_tools', 19 | description: 20 | 'Tools for managing Backlog space settings and general information.', 21 | enabled: true, 22 | tools: [ 23 | enableToolsetTool(toolRegistrar, helper), 24 | listAvailableToolsets(helper, toolsetGroup), 25 | getToolsetTools(helper, toolsetGroup), 26 | ], 27 | }, 28 | ], 29 | }; 30 | }; 31 | 32 | const enableToolsetSchema = buildToolSchema((t) => ({ 33 | toolset: z 34 | .string() 35 | .describe(t('TOOL_ENABLE_TOOLSET_TOOLSET', 'Enable a toolset')), 36 | })); 37 | 38 | export const enableToolsetTool = ( 39 | toolRegistrar: ToolRegistrar, 40 | { t }: TranslationHelper 41 | ): DynamicToolDefinition<ReturnType<typeof enableToolsetSchema>> => { 42 | return { 43 | name: 'enable_toolset', 44 | description: t( 45 | 'TOOL_ENABLE_TOOLSET_DESCRIPTION', 46 | 'Enable one of the sets of tools the GitHub MCP server provides, use get_toolset_tools and list_available_toolsets first to see what this will enable' 47 | ), 48 | schema: z.object(enableToolsetSchema(t)), 49 | handler: async ({ toolset }) => { 50 | const msg = await toolRegistrar.enableToolsetAndRefresh(toolset); 51 | return { 52 | content: [ 53 | { 54 | type: 'text', 55 | text: msg, 56 | }, 57 | ], 58 | }; 59 | }, 60 | }; 61 | }; 62 | 63 | export const listAvailableToolsets = ( 64 | { t }: TranslationHelper, 65 | toolsetGroup: ToolsetGroup 66 | ): DynamicToolDefinition<Record<string, never>> => { 67 | return { 68 | name: 'list_available_toolsets', 69 | description: t( 70 | 'TOOL_LIST_AVAILABLE_TOOLSETS_DESCRIPTION', 71 | 'List all available toolsets.' 72 | ), 73 | schema: z.object({}), 74 | handler: async () => { 75 | const result = toolsetGroup.toolsets.map((ts) => ({ 76 | name: ts.name, 77 | description: ts.description, 78 | currentlyEnabled: ts.enabled, 79 | canEnable: true, 80 | })); 81 | 82 | return { 83 | content: [ 84 | { 85 | type: 'text', 86 | text: JSON.stringify(result, null, 2), 87 | }, 88 | ], 89 | }; 90 | }, 91 | }; 92 | }; 93 | 94 | const getToolsetToolsSchema = buildToolSchema((t) => ({ 95 | toolset: z 96 | .string() 97 | .describe(t('TOOL_GET_TOOLSET_TOOLS_TOOLSET', 'Toolset name to inspect')), 98 | })); 99 | 100 | export const getToolsetTools = ( 101 | { t }: TranslationHelper, 102 | toolsetGroup: ToolsetGroup 103 | ): DynamicToolDefinition<ReturnType<typeof getToolsetToolsSchema>> => { 104 | return { 105 | name: 'get_toolset_tools', 106 | description: t( 107 | 'TOOL_GET_TOOLSET_TOOLS_DESCRIPTION', 108 | 'List all tools in a specific toolset.' 109 | ), 110 | schema: z.object(getToolsetToolsSchema(t)), 111 | handler: async ({ toolset }) => { 112 | const found = toolsetGroup.toolsets.find((ts) => ts.name === toolset); 113 | if (!found) { 114 | return { 115 | content: [ 116 | { 117 | type: 'text', 118 | text: `Toolset '${toolset}' not found.`, 119 | }, 120 | ], 121 | }; 122 | } 123 | 124 | const tools = found.tools.map((tool) => ({ 125 | name: tool.name, 126 | description: tool.description, 127 | toolset: found.name, 128 | canEnable: true, 129 | })); 130 | 131 | return { 132 | content: [ 133 | { 134 | type: 'text', 135 | text: JSON.stringify(tools, null, 2), 136 | }, 137 | ], 138 | }; 139 | }, 140 | }; 141 | }; 142 | ``` -------------------------------------------------------------------------------- /src/tools/addIssue.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Backlog } from 'backlog-js'; 2 | import { z } from 'zod'; 3 | import { TranslationHelper } from '../createTranslationHelper.js'; 4 | import { IssueSchema } from '../types/zod/backlogOutputDefinition.js'; 5 | import { buildToolSchema, ToolDefinition } from '../types/tool.js'; 6 | import { customFieldsToPayload } from '../backlog/customFields.js'; 7 | 8 | const addIssueSchema = buildToolSchema((t) => ({ 9 | projectId: z.number().describe(t('TOOL_ADD_ISSUE_PROJECT_ID', 'Project ID')), 10 | summary: z 11 | .string() 12 | .describe(t('TOOL_ADD_ISSUE_SUMMARY', 'Summary of the issue')), 13 | issueTypeId: z 14 | .number() 15 | .describe(t('TOOL_ADD_ISSUE_ISSUE_TYPE_ID', 'Issue type ID')), 16 | priorityId: z 17 | .number() 18 | .describe(t('TOOL_ADD_ISSUE_PRIORITY_ID', 'Priority ID')), 19 | description: z 20 | .string() 21 | .optional() 22 | .describe( 23 | t('TOOL_ADD_ISSUE_DESCRIPTION', 'Detailed description of the issue') 24 | ), 25 | startDate: z 26 | .string() 27 | .optional() 28 | .describe( 29 | t('TOOL_ADD_ISSUE_START_DATE', 'Scheduled start date (yyyy-MM-dd)') 30 | ), 31 | dueDate: z 32 | .string() 33 | .optional() 34 | .describe(t('TOOL_ADD_ISSUE_DUE_DATE', 'Scheduled due date (yyyy-MM-dd)')), 35 | estimatedHours: z 36 | .number() 37 | .optional() 38 | .describe(t('TOOL_ADD_ISSUE_ESTIMATED_HOURS', 'Estimated work hours')), 39 | actualHours: z 40 | .number() 41 | .optional() 42 | .describe(t('TOOL_ADD_ISSUE_ACTUAL_HOURS', 'Actual work hours')), 43 | categoryId: z 44 | .array(z.number()) 45 | .optional() 46 | .describe(t('TOOL_ADD_ISSUE_CATEGORY_ID', 'Category IDs')), 47 | versionId: z 48 | .array(z.number()) 49 | .optional() 50 | .describe(t('TOOL_ADD_ISSUE_VERSION_ID', 'Version IDs')), 51 | milestoneId: z 52 | .array(z.number()) 53 | .optional() 54 | .describe(t('TOOL_ADD_ISSUE_MILESTONE_ID', 'Milestone IDs')), 55 | assigneeId: z 56 | .number() 57 | .optional() 58 | .describe(t('TOOL_ADD_ISSUE_ASSIGNEE_ID', 'User ID of the assignee')), 59 | notifiedUserId: z 60 | .array(z.number()) 61 | .optional() 62 | .describe(t('TOOL_ADD_ISSUE_NOTIFIED_USER_ID', 'User IDs to notify')), 63 | attachmentId: z 64 | .array(z.number()) 65 | .optional() 66 | .describe(t('TOOL_ADD_ISSUE_ATTACHMENT_ID', 'Attachment IDs')), 67 | parentIssueId: z 68 | .number() 69 | .optional() 70 | .describe(t('TOOL_ADD_ISSUE_PARENT_ISSUE_ID', 'Parent issue ID')), 71 | customFields: z 72 | .array( 73 | z.object({ 74 | id: z 75 | .number() 76 | .describe( 77 | t( 78 | 'TOOL_ADD_ISSUE_CUSTOM_FIELD_ID', 79 | 'The ID of the custom field (e.g., 12345)' 80 | ) 81 | ), 82 | value: z.union([z.string().max(255), z.number(), z.array(z.string())]), 83 | otherValue: z 84 | .string() 85 | .optional() 86 | .describe( 87 | t( 88 | 'TOOL_ADD_ISSUE_CUSTOM_FIELD_OTHER_VALUE', 89 | 'Other value for list type fields' 90 | ) 91 | ), 92 | }) 93 | ) 94 | .optional() 95 | .describe( 96 | t( 97 | 'TOOL_ADD_ISSUE_CUSTOM_FIELDS', 98 | 'List of custom fields to set on the issue' 99 | ) 100 | ), 101 | })); 102 | 103 | export const addIssueTool = ( 104 | backlog: Backlog, 105 | { t }: TranslationHelper 106 | ): ToolDefinition< 107 | ReturnType<typeof addIssueSchema>, 108 | (typeof IssueSchema)['shape'] 109 | > => { 110 | return { 111 | name: 'add_issue', 112 | description: t( 113 | 'TOOL_ADD_ISSUE_DESCRIPTION', 114 | 'Creates a new issue in the specified project.' 115 | ), 116 | schema: z.object(addIssueSchema(t)), 117 | outputSchema: IssueSchema, 118 | importantFields: ['summary', 'issueKey', 'description', 'createdUser'], 119 | handler: async ({ customFields, ...params }) => { 120 | const customFieldPayload = customFieldsToPayload(customFields); 121 | 122 | const finalPayload = { 123 | ...params, 124 | ...customFieldPayload, 125 | }; 126 | 127 | return backlog.postIssue(finalPayload); 128 | }, 129 | }; 130 | }; 131 | ``` -------------------------------------------------------------------------------- /src/tools/addIssue.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { addIssueTool } from './addIssue.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('addIssueTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | postIssue: jest.fn<() => Promise<any>>().mockResolvedValue({ 9 | id: 1, 10 | projectId: 100, 11 | issueKey: 'TEST-1', 12 | keyId: 1, 13 | issueType: { 14 | id: 2, 15 | projectId: 100, 16 | name: 'Bug', 17 | color: '#990000', 18 | displayOrder: 0, 19 | }, 20 | summary: 'Test Issue', 21 | description: 'This is a test issue', 22 | priority: { 23 | id: 3, 24 | name: 'Normal', 25 | }, 26 | status: { 27 | id: 1, 28 | name: 'Open', 29 | projectId: 100, 30 | color: '#ff0000', 31 | displayOrder: 0, 32 | }, 33 | assignee: { 34 | id: 5, 35 | userId: 'user', 36 | name: 'Test User', 37 | roleType: 1, 38 | lang: 'en', 39 | mailAddress: '[email protected]', 40 | lastLoginTime: '2023-01-01T00:00:00Z', 41 | }, 42 | startDate: '2023-01-01', 43 | dueDate: '2023-01-31', 44 | estimatedHours: 10, 45 | actualHours: 5, 46 | createdUser: { 47 | id: 1, 48 | userId: 'admin', 49 | name: 'Admin User', 50 | roleType: 1, 51 | lang: 'en', 52 | mailAddress: '[email protected]', 53 | lastLoginTime: '2023-01-01T00:00:00Z', 54 | }, 55 | created: '2023-01-01T00:00:00Z', 56 | updatedUser: { 57 | id: 1, 58 | userId: 'admin', 59 | name: 'Admin User', 60 | roleType: 1, 61 | lang: 'en', 62 | mailAddress: '[email protected]', 63 | lastLoginTime: '2023-01-01T00:00:00Z', 64 | }, 65 | updated: '2023-01-01T00:00:00Z', 66 | }), 67 | }; 68 | 69 | const mockTranslationHelper = createTranslationHelper(); 70 | const tool = addIssueTool(mockBacklog as Backlog, mockTranslationHelper); 71 | 72 | it('returns created issue as formatted JSON text', async () => { 73 | const result = await tool.handler({ 74 | projectId: 100, 75 | summary: 'Test Issue', 76 | issueTypeId: 2, 77 | priorityId: 3, 78 | description: 'This is a test issue', 79 | startDate: '2023-01-01', 80 | dueDate: '2023-01-31', 81 | estimatedHours: 10, 82 | actualHours: 5, 83 | }); 84 | if (Array.isArray(result)) { 85 | throw new Error('Unexpected array result'); 86 | } 87 | 88 | expect(result.summary).toContain('Test Issue'); 89 | expect(result.description).toContain('This is a test issue'); 90 | }); 91 | 92 | it('calls backlog.postIssue with correct params', async () => { 93 | await tool.handler({ 94 | projectId: 100, 95 | summary: 'Test Issue', 96 | issueTypeId: 2, 97 | priorityId: 3, 98 | description: 'This is a test issue', 99 | startDate: '2023-01-01', 100 | dueDate: '2023-01-31', 101 | estimatedHours: 10, 102 | actualHours: 5, 103 | }); 104 | 105 | expect(mockBacklog.postIssue).toHaveBeenCalledWith({ 106 | projectId: 100, 107 | summary: 'Test Issue', 108 | issueTypeId: 2, 109 | priorityId: 3, 110 | description: 'This is a test issue', 111 | startDate: '2023-01-01', 112 | dueDate: '2023-01-31', 113 | estimatedHours: 10, 114 | actualHours: 5, 115 | }); 116 | }); 117 | 118 | it('transforms customFields to proper customField_{id} format', async () => { 119 | await tool.handler({ 120 | projectId: 100, 121 | summary: 'Custom Field Test', 122 | issueTypeId: 2, 123 | priorityId: 3, 124 | customFields: [ 125 | { id: 123, value: 'テキスト' }, 126 | { id: 456, value: 42 }, 127 | { id: 789, value: ['OptionA', 'OptionB'], otherValue: '詳細説明' }, 128 | ], 129 | }); 130 | 131 | expect(mockBacklog.postIssue).toHaveBeenCalledWith( 132 | expect.objectContaining({ 133 | projectId: 100, 134 | summary: 'Custom Field Test', 135 | issueTypeId: 2, 136 | priorityId: 3, 137 | customField_123: 'テキスト', 138 | customField_456: 42, 139 | customField_789: ['OptionA', 'OptionB'], 140 | customField_789_otherValue: '詳細説明', 141 | }) 142 | ); 143 | }); 144 | }); 145 | ``` -------------------------------------------------------------------------------- /src/tools/getCustomFields.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { jest, describe, it, expect } from '@jest/globals'; 2 | import type { Backlog } from 'backlog-js'; 3 | import * as Entity from 'backlog-js/dist/types/entity'; // To access Entity.Project.CustomField 4 | import { getCustomFieldsTool } from './getCustomFields.js'; 5 | import { createTranslationHelper } from '../createTranslationHelper.js'; 6 | 7 | describe('getCustomFieldsTool', () => { 8 | // Define mockBacklog with the specific method we need 9 | const mockBacklog: Partial<Backlog> = { 10 | // Specify the correct return type for the mock 11 | getCustomFields: jest.fn<() => Promise<Entity.Project.CustomField[]>>(), 12 | }; 13 | 14 | // Use the actual createTranslationHelper for consistency 15 | const mockTranslationHelper = createTranslationHelper(); 16 | 17 | // Instantiate the tool with the mocked Backlog and real TranslationHelper 18 | const tool = getCustomFieldsTool( 19 | mockBacklog as Backlog, 20 | mockTranslationHelper 21 | ); 22 | const toolHandler = tool.handler; // Get the handler from the instantiated tool 23 | 24 | it('should return custom fields for a valid project ID', async () => { 25 | const mockCustomFieldsData: Entity.Project.CustomField[] = [ 26 | { 27 | id: 1, 28 | projectId: 1, 29 | typeId: 1, 30 | name: 'CF1', 31 | description: '', 32 | required: false, 33 | applicableIssueTypes: [], 34 | } as Entity.Project.CustomField, 35 | { 36 | id: 2, 37 | projectId: 1, 38 | typeId: 2, 39 | name: 'CF2', 40 | description: 'Desc', 41 | required: true, 42 | applicableIssueTypes: [1], 43 | } as Entity.Project.CustomField, 44 | ]; 45 | 46 | // Setup the mockResolvedValue for getCustomFields 47 | ( 48 | mockBacklog.getCustomFields as jest.Mock< 49 | () => Promise<Entity.Project.CustomField[]> 50 | > 51 | ).mockResolvedValue(mockCustomFieldsData); 52 | 53 | const input = { projectKey: 'TEST_PROJECT' }; 54 | const result = await toolHandler(input); 55 | 56 | expect(mockBacklog.getCustomFields).toHaveBeenCalledWith('TEST_PROJECT'); 57 | expect(result).toEqual(mockCustomFieldsData); 58 | }); 59 | 60 | it('should call backlog.getCustomFields with correct params when using project ID', async () => { 61 | ( 62 | mockBacklog.getCustomFields as jest.Mock< 63 | () => Promise<Entity.Project.CustomField[]> 64 | > 65 | ).mockResolvedValue([]); // Return empty for this check 66 | await toolHandler({ projectId: 123 }); 67 | expect(mockBacklog.getCustomFields).toHaveBeenCalledWith(123); 68 | }); 69 | 70 | it('should throw an error if getCustomFields fails', async () => { 71 | const apiError = new Error('API error'); 72 | ( 73 | mockBacklog.getCustomFields as jest.Mock< 74 | () => Promise<Entity.Project.CustomField[]> 75 | > 76 | ).mockRejectedValue(apiError); 77 | 78 | const input = { projectKey: 'TEST_PROJECT_FAIL' }; 79 | // Expect the handler to throw the error directly 80 | await expect(toolHandler(input)).rejects.toThrow(apiError); 81 | expect(mockBacklog.getCustomFields).toHaveBeenCalledWith( 82 | 'TEST_PROJECT_FAIL' 83 | ); 84 | }); 85 | 86 | it('should throw a structured error if API returns structured error', async () => { 87 | const structuredError = { 88 | message: 'Structured error from API', // This is the top-level message 89 | errors: [ 90 | { message: 'Invalid request detail', code: 6, moreInfo: 'Some info' }, 91 | ], 92 | }; 93 | ( 94 | mockBacklog.getCustomFields as jest.Mock< 95 | () => Promise<Entity.Project.CustomField[]> 96 | > 97 | ).mockRejectedValue(structuredError); 98 | 99 | const input = { projectKey: 'TEST_PROJECT_STRUCTURED_ERROR' }; 100 | // Expect the handler to throw the structured error directly 101 | await expect(toolHandler(input)).rejects.toEqual(structuredError); 102 | expect(mockBacklog.getCustomFields).toHaveBeenCalledWith( 103 | 'TEST_PROJECT_STRUCTURED_ERROR' 104 | ); 105 | }); 106 | 107 | it('throws an error if neither projectId nor projectKey is provided', async () => { 108 | const params = {}; // No identifier provided 109 | 110 | await expect(toolHandler(params as any)).rejects.toThrow(Error); 111 | }); 112 | }); 113 | ``` -------------------------------------------------------------------------------- /src/tools/getIssues.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getIssuesTool } from './getIssues.js'; 2 | import { jest, describe, it, expect } from '@jest/globals'; 3 | import type { Backlog } from 'backlog-js'; 4 | import { createTranslationHelper } from '../createTranslationHelper.js'; 5 | 6 | describe('getIssuesTool', () => { 7 | const mockBacklog: Partial<Backlog> = { 8 | getIssues: jest.fn<() => Promise<any>>().mockResolvedValue([ 9 | { 10 | id: 1, 11 | projectId: 100, 12 | issueKey: 'TEST-1', 13 | keyId: 1, 14 | issueType: { 15 | id: 2, 16 | projectId: 100, 17 | name: 'Bug', 18 | color: '#990000', 19 | displayOrder: 0, 20 | }, 21 | summary: 'Test Issue 1', 22 | description: 'This is test issue 1', 23 | status: { 24 | id: 1, 25 | name: 'Open', 26 | projectId: 100, 27 | color: '#ff0000', 28 | displayOrder: 0, 29 | }, 30 | priority: { 31 | id: 3, 32 | name: 'Normal', 33 | }, 34 | created: '2023-01-01T00:00:00Z', 35 | updated: '2023-01-01T00:00:00Z', 36 | }, 37 | { 38 | id: 2, 39 | projectId: 100, 40 | issueKey: 'TEST-2', 41 | keyId: 2, 42 | issueType: { 43 | id: 2, 44 | projectId: 100, 45 | name: 'Bug', 46 | color: '#990000', 47 | displayOrder: 0, 48 | }, 49 | summary: 'Test Issue 2', 50 | description: 'This is test issue 2', 51 | status: { 52 | id: 1, 53 | name: 'Open', 54 | projectId: 100, 55 | color: '#ff0000', 56 | displayOrder: 0, 57 | }, 58 | priority: { 59 | id: 3, 60 | name: 'Normal', 61 | }, 62 | created: '2023-01-02T00:00:00Z', 63 | updated: '2023-01-02T00:00:00Z', 64 | }, 65 | ]), 66 | }; 67 | 68 | const mockTranslationHelper = createTranslationHelper(); 69 | const tool = getIssuesTool(mockBacklog as Backlog, mockTranslationHelper); 70 | 71 | it('returns issues as formatted JSON text', async () => { 72 | const result = await tool.handler({ 73 | projectId: [100], 74 | }); 75 | 76 | if (!Array.isArray(result)) { 77 | throw new Error('Unexpected non array result'); 78 | } 79 | 80 | expect(result).toHaveLength(2); 81 | expect(result[0].summary).toEqual('Test Issue 1'); 82 | expect(result[1].summary).toEqual('Test Issue 2'); 83 | }); 84 | 85 | it('calls backlog.getIssues with correct params', async () => { 86 | const params = { 87 | projectId: [100], 88 | statusId: [1], 89 | sort: 'updated' as const, 90 | order: 'desc' as const, 91 | count: 10, 92 | }; 93 | 94 | await tool.handler(params); 95 | 96 | expect(mockBacklog.getIssues).toHaveBeenCalledWith(params); 97 | }); 98 | 99 | it('calls backlog.getIssues with keyword search', async () => { 100 | await tool.handler({ 101 | keyword: 'bug', 102 | }); 103 | 104 | expect(mockBacklog.getIssues).toHaveBeenCalledWith({ 105 | keyword: 'bug', 106 | }); 107 | }); 108 | 109 | it('calls backlog.getIssues with custom fields', async () => { 110 | await tool.handler({ 111 | projectId: [100], 112 | customFields: [ 113 | { id: 12345, value: 'test-value' }, 114 | { id: 67890, value: 123 }, 115 | ], 116 | }); 117 | 118 | expect(mockBacklog.getIssues).toHaveBeenCalledWith({ 119 | projectId: [100], 120 | customField_12345: 'test-value', 121 | customField_67890: 123, 122 | }); 123 | }); 124 | 125 | it('calls backlog.getIssues with custom fields array values', async () => { 126 | await tool.handler({ 127 | customFields: [{ id: 11111, value: ['option1', 'option2'] }], 128 | }); 129 | 130 | expect(mockBacklog.getIssues).toHaveBeenCalledWith({ 131 | customField_11111: ['option1', 'option2'], 132 | }); 133 | }); 134 | 135 | it('calls backlog.getIssues with empty custom fields', async () => { 136 | await tool.handler({ 137 | projectId: [100], 138 | customFields: [], 139 | }); 140 | 141 | expect(mockBacklog.getIssues).toHaveBeenCalledWith({ 142 | projectId: [100], 143 | }); 144 | }); 145 | 146 | it('calls backlog.getIssues without custom fields', async () => { 147 | await tool.handler({ 148 | projectId: [100], 149 | statusId: [1], 150 | }); 151 | 152 | expect(mockBacklog.getIssues).toHaveBeenCalledWith({ 153 | projectId: [100], 154 | statusId: [1], 155 | }); 156 | }); 157 | }); 158 | ``` -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- ```markdown 1 | # Changelog 2 | 3 | ## 0.4.0 (2025-07-23) 4 | 5 | ## [0.3.1](https://github.com/trknhr/backlog-mcp-server/compare/v0.3.0...v0.3.1) 6 | 7 | ### Features 8 | 9 | * Add custom fields support for getIssues and countIssues tools ([#9](https://github.com/trknhr/backlog-mcp-server/issues/9)) ([e6e42f4](https://github.com/trknhr/backlog-mcp-server/commit/e6e42f4b13116bbb12e1a333df616767a3c5b96e)) 10 | ## [0.3.0](https://github.com/trknhr/backlog-mcp-server/compare/v0.2.0...v0.3.0) (2025-05-30) 11 | 12 | ### Features 13 | 14 | * add dynamic toolset support and modular registration system ([6bc72e2](https://github.com/trknhr/backlog-mcp-server/commit/6bc72e2624ba6ecbb0e2f192c3792e17867969dd)) 15 | * **cli:** add `--prefix` option to prepend string to tool names ([a37c6b1](https://github.com/trknhr/backlog-mcp-server/commit/a37c6b1ef5f8324df2cec8d9c4e3f6bef4d9cc50)) 16 | ## [0.2.0](https://github.com/trknhr/backlog-mcp-server/compare/v0.1.1...v0.2.0) (2025-05-14) 17 | 18 | ### Features 19 | 20 | * **issue:** support structured custom field input via `customFields` ([12ab057](https://github.com/trknhr/backlog-mcp-server/commit/12ab057efcdced87408f3f09dba0f8a02e060c5a)), closes [#3](https://github.com/trknhr/backlog-mcp-server/issues/3) 21 | * **tools:** split project identifier into separate fields for ID and key across all tools ([8bbb772](https://github.com/trknhr/backlog-mcp-server/commit/8bbb772bce822d16a7315fc4508c3ea66d439402)) 22 | * **tools:** support explicit issueId/issueKey instead of issueIdOrKey ([858b301](https://github.com/trknhr/backlog-mcp-server/commit/858b30131ff1f70e3dccade875b0e1037867e079)), closes [#2](https://github.com/trknhr/backlog-mcp-server/issues/2) [#4](https://github.com/trknhr/backlog-mcp-server/issues/4) 23 | * **tools:** unify ID resolution for repositories using resolveIdOrName ([d228c2a](https://github.com/trknhr/backlog-mcp-server/commit/d228c2a594c7f703c1b843753b6f32a97078dba6)) 24 | 25 | ### Bug Fixes 26 | 27 | * **lint:** suppress no-undef error in JS files and remove `any` from customFields payload ([f9fd4ce](https://github.com/trknhr/backlog-mcp-server/commit/f9fd4ce56fc48d7bb89c31d16aef633ced92dfd1)) 28 | ## [0.1.1](https://github.com/trknhr/backlog-mcp-server/compare/v0.1.0...v0.1.1) (2025-05-08) 29 | 30 | ### Bug Fixes 31 | 32 | * **get_issue:** require issueIdOrKey as string to prevent invalid LLM input ([ea2a54f](https://github.com/trknhr/backlog-mcp-server/commit/ea2a54f0ec3a698a29ead2ff8f4469658bc0e5c6)) 33 | ## [0.1.0](https://github.com/trknhr/backlog-mcp-server/compare/v0.0.2...v0.1.0) (2025-05-01) 34 | 35 | ### Features 36 | 37 | * Add slim version with --optimize-response and refactor tools ([9345c72](https://github.com/trknhr/backlog-mcp-server/commit/9345c72e137eb57b2c4cea52468fefebae0ed453)) 38 | * **config:** add CLI and env-based resolvers for maxTokens and optimize-response ([c7c8e4b](https://github.com/trknhr/backlog-mcp-server/commit/c7c8e4b88647f74f28d60c3a16f45eb362f25be1)) 39 | * **handler:** add max token limit and refactor handler composition ([21d2279](https://github.com/trknhr/backlog-mcp-server/commit/21d22798599576dad230f76b75409cbfb7b71bae)) 40 | ## [0.0.2](https://github.com/trknhr/backlog-mcp-server/compare/v0.0.1...v0.0.2) (2025-04-24) 41 | 42 | ### Features 43 | 44 | * add error handling for all tools ([c4a0357](https://github.com/trknhr/backlog-mcp-server/commit/c4a03573f7d5aaa298590dcd23f5a948e76d29e5)) 45 | * **wiki:** add wiki creation tool ([9c0240c](https://github.com/trknhr/backlog-mcp-server/commit/9c0240cfdb2f288a9cabaa50d406459711f6c1df)) 46 | 47 | ### Bug Fixes 48 | 49 | * revise lint error ([2297494](https://github.com/trknhr/backlog-mcp-server/commit/229749450f08db180cf60885dba01ed010857d02)) 50 | ## [0.0.1](https://github.com/trknhr/backlog-mcp-server/compare/e64fc4a2acb0f5f18e885932df643430b9c163d4...v0.0.1) (2025-04-21) 51 | 52 | ### Features 53 | 54 | * Japanese labels for all endpoints and variables ([0b8a47c](https://github.com/trknhr/backlog-mcp-server/commit/0b8a47cae4eafb9c0b7e7137149d19472426950e)) 55 | 56 | ### Bug Fixes 57 | 58 | * add shebang to `index.ts` ([e64fc4a](https://github.com/trknhr/backlog-mcp-server/commit/e64fc4a2acb0f5f18e885932df643430b9c163d4)) 59 | ```