diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0bb3a8f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:www.redmine.org)", + "Bash(npm run build:*)", + "Bash(npm run lint:*)", + "Bash(npm test:*)", + "WebSearch", + "Bash(dir:*)", + "Bash(findstr:*)", + "Bash(mkdir:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..930c574 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build and Development Commands + +```bash +# Install dependencies +npm install + +# Build TypeScript to dist/ +npm run build + +# Run in development mode (with watch) +npm run dev + +# Start production server +npm start + +# Run tests +npm test + +# Run linting +npm run lint +``` + +## Environment Variables + +Required environment variables for development and testing: +- `REDMINE_API_KEY`: API key from Redmine user settings +- `REDMINE_HOST`: Redmine server URL (e.g., `https://redmine.example.com`) + +## Architecture Overview + +This is an MCP (Model Context Protocol) server that integrates with Redmine's REST API. The server exposes Redmine functionality as MCP tools that can be called by LLMs. + +### Core Flow + +1. **Entry Point** (`src/index.ts`): Bootstraps the server via `runServer()` +2. **Server Setup** (`src/handlers/index.ts`): Creates the MCP server, registers all tools, and handles tool execution routing +3. **Tool Execution**: Incoming tool calls are routed through a handler map to resource-specific handlers + +### Module Structure + +**Tools** (`src/tools/`): Define MCP tool schemas with names, descriptions, and input validation. Each file exports `*_TOOL` constants (e.g., `ISSUE_LIST_TOOL`, `PROJECT_CREATE_TOOL`). Tools are automatically discovered and registered. + +**Handlers** (`src/handlers/`): Implement tool logic. Each resource has a `create*Handlers()` function that returns an object mapping tool names to handler functions. Handlers receive a `HandlerContext` with the Redmine client, config, and logger. + +**Client** (`src/lib/client/`): Wraps Redmine REST API calls. `BaseClient` provides `performRequest()` for HTTP operations and `encodeQueryParams()` for query string building. Resource-specific clients extend `BaseClient`. + +**Types** (`src/lib/types/`): TypeScript types and Zod schemas for each resource domain (issues, projects, time_entries, users). Each has: +- `types.ts`: TypeScript interfaces +- `schema.ts`: Zod validation schemas +- `index.ts`: Re-exports + +**Formatters** (`src/formatters/`): Convert Redmine API responses to human-readable text for MCP tool responses. + +**Config** (`src/lib/config.ts`): Loads and validates environment variables using Zod. + +### Adding a New Tool + +1. Add tool definition in `src/tools/.ts` (export `*_TOOL` constant) +2. Add export in `src/tools/index.ts` +3. Implement handler in `src/handlers/.ts` +4. If new resource: create client in `src/lib/client/`, types in `src/lib/types/`, formatter in `src/formatters/` + +### Testing + +Tests use Jest with `node-fetch` mocks. Test files are located in `__tests__/` directories adjacent to the code they test. Only GET operations are included in tests for data safety. diff --git a/package-lock.json b/package-lock.json index c593a06..a29daa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,6 +76,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.0", @@ -1772,6 +1773,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~6.20.0" } @@ -1841,6 +1843,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.19.0.tgz", "integrity": "sha512-6M8taKyOETY1TKHp0x8ndycipTVgmp4xtg5QpEZzXxDhNvvHOJi5rLRkLr8SK3jTgD5l4fTlvBiRdfsuWydxBw==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.19.0", "@typescript-eslint/types": "8.19.0", @@ -2021,6 +2024,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2299,6 +2303,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -2768,6 +2773,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz", "integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -3594,6 +3600,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -5269,6 +5276,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -5365,6 +5373,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/formatters/attachments.ts b/src/formatters/attachments.ts new file mode 100644 index 0000000..6353ad5 --- /dev/null +++ b/src/formatters/attachments.ts @@ -0,0 +1,110 @@ +import type { + RedmineAttachment, + RedmineUploadResponse, +} from "../lib/types/attachments/index.js"; + +/** + * Escape XML special characters + */ +function escapeXml(unsafe: string | null | undefined): string { + if (unsafe === null || unsafe === undefined) { + return ""; + } + return unsafe + .replace(/[&]/g, "&") + .replace(/[<]/g, "<") + .replace(/[>]/g, ">") + .replace(/["]/g, """) + .replace(/[']/g, "'"); +} + +/** + * Format file size to human readable format + */ +function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +} + +/** + * Format a single attachment + */ +export function formatAttachment(attachment: RedmineAttachment): string { + return ` + ${attachment.id} + ${escapeXml(attachment.filename)} + ${formatFileSize(attachment.filesize)} + ${escapeXml(attachment.content_type)} + ${attachment.description ? `${escapeXml(attachment.description)}` : ""} + ${escapeXml(attachment.author.name)} + ${attachment.created_on} + ${escapeXml(attachment.content_url)} + ${attachment.thumbnail_url ? `${escapeXml(attachment.thumbnail_url)}` : ""} +`; +} + +/** + * Format list of attachments + */ +export function formatAttachments(attachments: RedmineAttachment[]): string { + if (!attachments || attachments.length === 0) { + return ` +`; + } + + const formatted = attachments.map(formatAttachment).join("\n"); + + return ` + +${formatted} +`; +} + +/** + * Format upload response + */ +export function formatUploadResponse( + response: RedmineUploadResponse, + filename: string +): string { + return ` + + success + File "${escapeXml(filename)}" uploaded successfully + ${escapeXml(response.upload.token)} + Use this token with create_issue or update_issue to attach the file: + { + "uploads": [ + { + "token": "${escapeXml(response.upload.token)}", + "filename": "${escapeXml(filename)}", + "content_type": "application/octet-stream" + } + ] + } + +`; +} + +/** + * Format download response + */ +export function formatDownloadResponse( + attachment: RedmineAttachment, + base64Content: string +): string { + return ` + + success + + ${attachment.id} + ${escapeXml(attachment.filename)} + ${formatFileSize(attachment.filesize)} + ${escapeXml(attachment.content_type)} + + ${base64Content} +`; +} diff --git a/src/formatters/enumerations.ts b/src/formatters/enumerations.ts new file mode 100644 index 0000000..4e89a85 --- /dev/null +++ b/src/formatters/enumerations.ts @@ -0,0 +1,50 @@ +import { RedmineEnumeration } from "../lib/types/index.js"; + +/** + * Format issue priorities list + */ +export function formatIssuePriorities(priorities: RedmineEnumeration[]): string { + return formatEnumerations(priorities, "issue_priorities", "priority"); +} + +/** + * Format time entry activities list + */ +export function formatTimeEntryActivities(activities: RedmineEnumeration[]): string { + return formatEnumerations(activities, "time_entry_activities", "activity"); +} + +/** + * Format document categories list + */ +export function formatDocumentCategories(categories: RedmineEnumeration[]): string { + return formatEnumerations(categories, "document_categories", "category"); +} + +/** + * Generic enumeration formatter + */ +function formatEnumerations( + items: RedmineEnumeration[], + rootTag: string, + itemTag: string +): string { + if (items.length === 0) { + return `<${rootTag}>No items found`; + } + + const lines = items.map((item) => { + return ` <${itemTag} id="${item.id}" is_default="${item.is_default}">${escapeXml(item.name)}`; + }); + + return `<${rootTag} count="${items.length}">\n${lines.join("\n")}\n`; +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/src/formatters/index.ts b/src/formatters/index.ts index 3888adf..321e8d2 100644 --- a/src/formatters/index.ts +++ b/src/formatters/index.ts @@ -26,4 +26,45 @@ export { formatUsers, formatUserResult, formatUserDeleted, -} from "./users.js"; \ No newline at end of file +} from "./users.js"; + +export { formatIssueStatuses } from "./issue_statuses.js"; + +export { formatTrackers } from "./trackers.js"; + +export { + formatIssuePriorities, + formatTimeEntryActivities, + formatDocumentCategories, +} from "./enumerations.js"; + +export { + formatVersions, + formatVersion, + formatVersionResult, + formatVersionDeleted, +} from "./versions.js"; + +export { + formatMemberships, + formatMembership, + formatMembershipResult, + formatMembershipDeleted, + formatMembershipUpdated, +} from "./memberships.js"; + +export { formatRoles, formatRole } from "./roles.js"; + +export { + formatIssueCategories, + formatIssueCategory, + formatIssueCategoryResult, + formatIssueCategoryDeleted, +} from "./issue_categories.js"; + +export { + formatAttachment, + formatAttachments, + formatUploadResponse, + formatDownloadResponse, +} from "./attachments.js"; \ No newline at end of file diff --git a/src/formatters/issue_categories.ts b/src/formatters/issue_categories.ts new file mode 100644 index 0000000..4055f29 --- /dev/null +++ b/src/formatters/issue_categories.ts @@ -0,0 +1,62 @@ +import { RedmineIssueCategory } from "../lib/types/index.js"; + +/** + * Format issue categories list + */ +export function formatIssueCategories(categories: RedmineIssueCategory[]): string { + if (categories.length === 0) { + return "No issue categories found"; + } + + const lines = categories.map((category) => formatIssueCategorySummary(category)); + return `\n${lines.join("\n")}\n`; +} + +/** + * Format single issue category (detailed) + */ +export function formatIssueCategory(category: RedmineIssueCategory): string { + let result = `\n`; + result += ` ${escapeXml(category.name)}\n`; + result += ` ${escapeXml(category.project.name)}\n`; + + if (category.assigned_to) { + result += ` ${escapeXml(category.assigned_to.name)}\n`; + } + + result += ``; + return result; +} + +/** + * Format issue category result (create/update) + */ +export function formatIssueCategoryResult(category: RedmineIssueCategory, action: string): string { + return `\n success\n ${action}\n${formatIssueCategory(category)}\n`; +} + +/** + * Format issue category deleted + */ +export function formatIssueCategoryDeleted(id: number): string { + return `\n success\n deleted\n ${id}\n`; +} + +function formatIssueCategorySummary(category: RedmineIssueCategory): string { + let result = ` \n`; + result += ` ${escapeXml(category.name)}\n`; + if (category.assigned_to) { + result += ` ${escapeXml(category.assigned_to.name)}\n`; + } + result += ` `; + return result; +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/src/formatters/issue_statuses.ts b/src/formatters/issue_statuses.ts new file mode 100644 index 0000000..ac279c4 --- /dev/null +++ b/src/formatters/issue_statuses.ts @@ -0,0 +1,25 @@ +import { RedmineIssueStatus } from "../lib/types/index.js"; + +/** + * Format issue statuses list + */ +export function formatIssueStatuses(statuses: RedmineIssueStatus[]): string { + if (statuses.length === 0) { + return "No issue statuses found"; + } + + const lines = statuses.map((status) => { + return ` ${escapeXml(status.name)}`; + }); + + return `\n${lines.join("\n")}\n`; +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/src/formatters/memberships.ts b/src/formatters/memberships.ts new file mode 100644 index 0000000..c27f48f --- /dev/null +++ b/src/formatters/memberships.ts @@ -0,0 +1,84 @@ +import { RedmineMembership } from "../lib/types/index.js"; + +/** + * Format memberships list + */ +export function formatMemberships(memberships: RedmineMembership[], totalCount: number): string { + if (memberships.length === 0) { + return "No memberships found"; + } + + const lines = memberships.map((membership) => formatMembershipSummary(membership)); + return `\n${lines.join("\n")}\n`; +} + +/** + * Format single membership (detailed) + */ +export function formatMembership(membership: RedmineMembership): string { + let result = `\n`; + result += ` ${escapeXml(membership.project.name)}\n`; + + if (membership.user) { + result += ` ${escapeXml(membership.user.name)}\n`; + } + if (membership.group) { + result += ` ${escapeXml(membership.group.name)}\n`; + } + + const roleLines = membership.roles.map((role) => { + const inherited = role.inherited ? ` inherited="true"` : ""; + return ` ${escapeXml(role.name)}`; + }); + result += ` \n${roleLines.join("\n")}\n \n`; + result += ``; + + return result; +} + +/** + * Format membership result (create) + */ +export function formatMembershipResult(membership: RedmineMembership, action: string): string { + return `\n success\n ${action}\n${formatMembership(membership)}\n`; +} + +/** + * Format membership deleted + */ +export function formatMembershipDeleted(id: number): string { + return `\n success\n deleted\n ${id}\n`; +} + +/** + * Format membership updated + */ +export function formatMembershipUpdated(id: number): string { + return `\n success\n updated\n ${id}\n`; +} + +function formatMembershipSummary(membership: RedmineMembership): string { + let result = ` \n`; + result += ` ${escapeXml(membership.project.name)}\n`; + + if (membership.user) { + result += ` ${escapeXml(membership.user.name)}\n`; + } + if (membership.group) { + result += ` ${escapeXml(membership.group.name)}\n`; + } + + const roleNames = membership.roles.map((r) => r.name).join(", "); + result += ` ${escapeXml(roleNames)}\n`; + result += ` `; + return result; +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/src/formatters/roles.ts b/src/formatters/roles.ts new file mode 100644 index 0000000..c77808c --- /dev/null +++ b/src/formatters/roles.ts @@ -0,0 +1,54 @@ +import { RedmineRole } from "../lib/types/index.js"; + +/** + * Format roles list + */ +export function formatRoles(roles: RedmineRole[]): string { + if (roles.length === 0) { + return "No roles found"; + } + + const lines = roles.map((role) => { + return ` ${escapeXml(role.name)}`; + }); + + return `\n${lines.join("\n")}\n`; +} + +/** + * Format single role (detailed with permissions) + */ +export function formatRole(role: RedmineRole): string { + let result = `\n`; + result += ` ${escapeXml(role.name)}\n`; + + if (role.assignable !== undefined) { + result += ` ${role.assignable}\n`; + } + if (role.issues_visibility) { + result += ` ${escapeXml(role.issues_visibility)}\n`; + } + if (role.time_entries_visibility) { + result += ` ${escapeXml(role.time_entries_visibility)}\n`; + } + if (role.users_visibility) { + result += ` ${escapeXml(role.users_visibility)}\n`; + } + + if (role.permissions && role.permissions.length > 0) { + const permLines = role.permissions.map((p) => ` ${escapeXml(p)}`); + result += ` \n${permLines.join("\n")}\n \n`; + } + + result += ``; + return result; +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/src/formatters/trackers.ts b/src/formatters/trackers.ts new file mode 100644 index 0000000..9c962f1 --- /dev/null +++ b/src/formatters/trackers.ts @@ -0,0 +1,37 @@ +import { RedmineTracker } from "../lib/types/index.js"; + +/** + * Format trackers list + */ +export function formatTrackers(trackers: RedmineTracker[]): string { + if (trackers.length === 0) { + return "No trackers found"; + } + + const lines = trackers.map((tracker) => { + let line = ` \n`; + line += ` ${escapeXml(tracker.name)}\n`; + if (tracker.default_status) { + line += ` ${escapeXml(tracker.default_status.name)}\n`; + } + if (tracker.description) { + line += ` ${escapeXml(tracker.description)}\n`; + } + if (tracker.enabled_standard_fields && tracker.enabled_standard_fields.length > 0) { + line += ` ${tracker.enabled_standard_fields.join(", ")}\n`; + } + line += ` `; + return line; + }); + + return `\n${lines.join("\n")}\n`; +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/src/formatters/versions.ts b/src/formatters/versions.ts new file mode 100644 index 0000000..d731211 --- /dev/null +++ b/src/formatters/versions.ts @@ -0,0 +1,80 @@ +import { RedmineVersion } from "../lib/types/index.js"; + +/** + * Format versions list + */ +export function formatVersions(versions: RedmineVersion[]): string { + if (versions.length === 0) { + return "No versions found"; + } + + const lines = versions.map((version) => formatVersionSummary(version)); + return `\n${lines.join("\n")}\n`; +} + +/** + * Format single version (detailed) + */ +export function formatVersion(version: RedmineVersion): string { + let result = `\n`; + result += ` ${escapeXml(version.name)}\n`; + result += ` ${escapeXml(version.project.name)}\n`; + result += ` ${version.status}\n`; + result += ` ${version.sharing}\n`; + + if (version.description) { + result += ` ${escapeXml(version.description)}\n`; + } + if (version.due_date) { + result += ` ${version.due_date}\n`; + } + if (version.wiki_page_title) { + result += ` ${escapeXml(version.wiki_page_title)}\n`; + } + if (version.estimated_hours !== undefined) { + result += ` ${version.estimated_hours}\n`; + } + if (version.spent_hours !== undefined) { + result += ` ${version.spent_hours}\n`; + } + + result += ` ${version.created_on}\n`; + result += ` ${version.updated_on}\n`; + result += ``; + + return result; +} + +/** + * Format version result (create/update) + */ +export function formatVersionResult(version: RedmineVersion, action: string): string { + return `\n success\n ${action}\n${formatVersion(version)}\n`; +} + +/** + * Format version deleted + */ +export function formatVersionDeleted(id: number): string { + return `\n success\n deleted\n ${id}\n`; +} + +function formatVersionSummary(version: RedmineVersion): string { + let result = ` \n`; + result += ` ${escapeXml(version.name)}\n`; + result += ` ${escapeXml(version.project.name)}\n`; + if (version.due_date) { + result += ` ${version.due_date}\n`; + } + result += ` `; + return result; +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts new file mode 100644 index 0000000..3afbb75 --- /dev/null +++ b/src/handlers/attachments.ts @@ -0,0 +1,322 @@ +import { + HandlerContext, + ToolResponse, + asNumber, + ValidationError, +} from "./types.js"; +import * as formatters from "../formatters/index.js"; +import { readFile } from "fs/promises"; +import { basename } from "path"; + +/** + * Creates handlers for attachment-related operations + * @param context Handler context containing the Redmine client and config + * @returns Object containing all attachment-related handlers + */ +export function createAttachmentsHandlers(context: HandlerContext) { + const { client } = context; + + return { + /** + * Upload a file to Redmine + * Returns a token that can be used to attach the file to an issue + */ + upload_file: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + + if (!("filename" in argsObj)) { + throw new ValidationError("filename is required"); + } + if (!("content_base64" in argsObj)) { + throw new ValidationError("content_base64 is required"); + } + + const filename = String(argsObj.filename); + const contentBase64 = String(argsObj.content_base64); + + const response = await client.attachments.uploadFile( + filename, + contentBase64 + ); + + return { + content: [ + { + type: "text", + text: formatters.formatUploadResponse(response, filename), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Get attachment details by ID + */ + get_attachment: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + + if (!("id" in argsObj)) { + throw new ValidationError("id is required"); + } + + const id = asNumber(argsObj.id); + const response = await client.attachments.getAttachment(id); + + return { + content: [ + { + type: "text", + text: formatters.formatAttachment(response.attachment), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Update an attachment (filename and/or description) + */ + update_attachment: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + + if (!("id" in argsObj)) { + throw new ValidationError("id is required"); + } + + const id = asNumber(argsObj.id); + const updateData: { filename?: string; description?: string } = {}; + + if ("filename" in argsObj) { + updateData.filename = String(argsObj.filename); + } + if ("description" in argsObj) { + updateData.description = String(argsObj.description); + } + + if (Object.keys(updateData).length === 0) { + throw new ValidationError( + "At least one of filename or description must be provided" + ); + } + + await client.attachments.updateAttachment(id, updateData); + + return { + content: [ + { + type: "text", + text: `Attachment #${id} updated successfully`, + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Delete an attachment + */ + delete_attachment: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + + if (!("id" in argsObj)) { + throw new ValidationError("id is required"); + } + + const id = asNumber(argsObj.id); + await client.attachments.deleteAttachment(id); + + return { + content: [ + { + type: "text", + text: `Attachment #${id} deleted successfully`, + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Download an attachment file content + */ + download_attachment: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + + if (!("id" in argsObj)) { + throw new ValidationError("id is required"); + } + + const id = asNumber(argsObj.id); + + // First get attachment details + const attachmentResponse = await client.attachments.getAttachment(id); + const attachment = attachmentResponse.attachment; + + // Download the file content + const fileContent = await client.attachments.downloadAttachment( + attachment + ); + + // Convert to base64 + const base64Content = fileContent.toString("base64"); + + return { + content: [ + { + type: "text", + text: formatters.formatDownloadResponse(attachment, base64Content), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Upload a file from local filesystem path + * Reads the file, converts to base64, and uploads to Redmine + */ + upload_file_from_path: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + + if (!("file_path" in argsObj)) { + throw new ValidationError("file_path is required"); + } + + const filePath = String(argsObj.file_path); + + // Use provided filename or extract from path + const filename = "filename" in argsObj + ? String(argsObj.filename) + : basename(filePath); + + // Read file from filesystem + const fileBuffer = await readFile(filePath); + + // Upload to Redmine + const response = await client.attachments.uploadFile( + filename, + fileBuffer + ); + + return { + content: [ + { + type: "text", + text: formatters.formatUploadResponse(response, filename), + }, + ], + isError: false, + }; + } catch (error) { + // Handle file not found error specifically + if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') { + return { + content: [ + { + type: "text", + text: `File not found: ${(error as NodeJS.ErrnoException).path || 'unknown path'}`, + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + }; +} diff --git a/src/handlers/enumerations.ts b/src/handlers/enumerations.ts new file mode 100644 index 0000000..7a4cb44 --- /dev/null +++ b/src/handlers/enumerations.ts @@ -0,0 +1,95 @@ +import { HandlerContext, ToolResponse } from "./types.js"; +import * as formatters from "../formatters/index.js"; + +/** + * Creates handlers for enumeration-related operations + */ +export function createEnumerationsHandlers(context: HandlerContext) { + const { client } = context; + + return { + /** + * Lists all issue priorities + */ + list_issue_priorities: async (): Promise => { + try { + const response = await client.enumerations.getIssuePriorities(); + return { + content: [ + { + type: "text", + text: formatters.formatIssuePriorities(response.issue_priorities), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Lists all time entry activities + */ + list_time_entry_activities: async (): Promise => { + try { + const response = await client.enumerations.getTimeEntryActivities(); + return { + content: [ + { + type: "text", + text: formatters.formatTimeEntryActivities(response.time_entry_activities), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Lists all document categories + */ + list_document_categories: async (): Promise => { + try { + const response = await client.enumerations.getDocumentCategories(); + return { + content: [ + { + type: "text", + text: formatters.formatDocumentCategories(response.document_categories), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + }; +} diff --git a/src/handlers/index.ts b/src/handlers/index.ts index 2d1c085..c46c33b 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -9,12 +9,20 @@ import { import { redmineClient } from "../lib/client/index.js"; import config from "../lib/config.js"; import * as tools from "../tools/index.js"; -import { HandlerContext } from "./types.js"; +import { HandlerContext } from "./types.js"; import { createIssuesHandlers } from "./issues.js"; import { createProjectsHandlers } from "./projects.js"; import { createTimeEntriesHandlers } from "./time_entries.js"; import { createUserHandlers } from "./users.js"; -import { formatAllowedStatuses } from "../formatters/projects.js"; // Import the new formatter +import { createIssueStatusesHandlers } from "./issue_statuses.js"; +import { createTrackersHandlers } from "./trackers.js"; +import { createEnumerationsHandlers } from "./enumerations.js"; +import { createVersionsHandlers } from "./versions.js"; +import { createMembershipsHandlers } from "./memberships.js"; +import { createRolesHandlers } from "./roles.js"; +import { createIssueCategoriesHandlers } from "./issue_categories.js"; +import { createAttachmentsHandlers } from "./attachments.js"; +import { formatAllowedStatuses } from "../formatters/projects.js"; // Create handler context const context: HandlerContext = { @@ -30,10 +38,17 @@ const context: HandlerContext = { // Create resource handlers const issuesHandlers = createIssuesHandlers(context); -// Pass the formatter function to createProjectsHandlers const projectsHandlers = createProjectsHandlers(context, formatAllowedStatuses); const timeEntriesHandlers = createTimeEntriesHandlers(context); const usersHandlers = createUserHandlers(context); +const issueStatusesHandlers = createIssueStatusesHandlers(context); +const trackersHandlers = createTrackersHandlers(context); +const enumerationsHandlers = createEnumerationsHandlers(context); +const versionsHandlers = createVersionsHandlers(context); +const membershipsHandlers = createMembershipsHandlers(context); +const rolesHandlers = createRolesHandlers(context); +const issueCategoriesHandlers = createIssueCategoriesHandlers(context); +const attachmentsHandlers = createAttachmentsHandlers(context); // Create handler map const handlers = { @@ -41,46 +56,16 @@ const handlers = { ...projectsHandlers, ...timeEntriesHandlers, ...usersHandlers, + ...issueStatusesHandlers, + ...trackersHandlers, + ...enumerationsHandlers, + ...versionsHandlers, + ...membershipsHandlers, + ...rolesHandlers, + ...issueCategoriesHandlers, + ...attachmentsHandlers, }; -// Available tools list -// The PROJECT_LIST_STATUSES_TOOL will be added in the tools/index.ts modification step -const TOOLS: Tool[] = [ - // Issue-related tools - tools.ISSUE_LIST_TOOL, - tools.ISSUE_GET_TOOL, - tools.ISSUE_CREATE_TOOL, - tools.ISSUE_UPDATE_TOOL, - tools.ISSUE_DELETE_TOOL, - tools.ISSUE_ADD_WATCHER_TOOL, - tools.ISSUE_REMOVE_WATCHER_TOOL, - - // Project-related tools - tools.PROJECT_LIST_TOOL, - tools.PROJECT_SHOW_TOOL, - tools.PROJECT_CREATE_TOOL, - tools.PROJECT_UPDATE_TOOL, - tools.PROJECT_ARCHIVE_TOOL, - tools.PROJECT_UNARCHIVE_TOOL, - tools.PROJECT_DELETE_TOOL, - tools.PROJECT_LIST_STATUSES_TOOL, // New tool for listing project statuses - - // Time entry tools - tools.TIME_ENTRY_LIST_TOOL, - tools.TIME_ENTRY_SHOW_TOOL, - tools.TIME_ENTRY_CREATE_FOR_ISSUE_TOOL, - tools.TIME_ENTRY_CREATE_FOR_PROJECT_TOOL, - tools.TIME_ENTRY_UPDATE_TOOL, - tools.TIME_ENTRY_DELETE_TOOL, - - // User-related tools - tools.USER_LIST_TOOL, - tools.USER_SHOW_TOOL, - tools.USER_CREATE_TOOL, - tools.USER_UPDATE_TOOL, - tools.USER_DELETE_TOOL, -]; - // Initialize server const server = new Server( { diff --git a/src/handlers/issue_categories.ts b/src/handlers/issue_categories.ts new file mode 100644 index 0000000..bc605a3 --- /dev/null +++ b/src/handlers/issue_categories.ts @@ -0,0 +1,234 @@ +import { + HandlerContext, + ToolResponse, + ValidationError, + asNumber, +} from "./types.js"; +import * as formatters from "../formatters/index.js"; +import type { RedmineIssueCategoryCreate, RedmineIssueCategoryUpdate } from "../lib/types/index.js"; + +/** + * Creates handlers for issue category-related operations + */ +export function createIssueCategoriesHandlers(context: HandlerContext) { + const { client } = context; + + return { + /** + * Lists issue categories for a project + */ + list_issue_categories: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + if (!("project_id" in argsObj)) { + throw new ValidationError("project_id is required"); + } + + const projectId = String(argsObj.project_id); + const response = await client.issueCategories.getIssueCategories(projectId); + + return { + content: [ + { + type: "text", + text: formatters.formatIssueCategories(response.issue_categories), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Shows a specific issue category + */ + show_issue_category: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + if (!("id" in argsObj)) { + throw new ValidationError("id is required"); + } + + const id = asNumber(argsObj.id); + const response = await client.issueCategories.getIssueCategory(id); + + return { + content: [ + { + type: "text", + text: formatters.formatIssueCategory(response.issue_category), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Creates a new issue category + */ + create_issue_category: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + if (!("project_id" in argsObj)) { + throw new ValidationError("project_id is required"); + } + if (!("name" in argsObj)) { + throw new ValidationError("name is required"); + } + + const projectId = String(argsObj.project_id); + const categoryData: RedmineIssueCategoryCreate = { + name: String(argsObj.name), + }; + + if ("assigned_to_id" in argsObj) { + categoryData.assigned_to_id = asNumber(argsObj.assigned_to_id); + } + + const response = await client.issueCategories.createIssueCategory(projectId, categoryData); + + return { + content: [ + { + type: "text", + text: formatters.formatIssueCategoryResult(response.issue_category, "created"), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Updates an issue category + */ + update_issue_category: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + if (!("id" in argsObj)) { + throw new ValidationError("id is required"); + } + + const id = asNumber(argsObj.id); + const categoryData: RedmineIssueCategoryUpdate = {}; + + if ("name" in argsObj) { + categoryData.name = String(argsObj.name); + } + if ("assigned_to_id" in argsObj) { + categoryData.assigned_to_id = asNumber(argsObj.assigned_to_id); + } + + const response = await client.issueCategories.updateIssueCategory(id, categoryData); + + return { + content: [ + { + type: "text", + text: formatters.formatIssueCategoryResult(response.issue_category, "updated"), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Deletes an issue category + */ + delete_issue_category: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + if (!("id" in argsObj)) { + throw new ValidationError("id is required"); + } + + const id = asNumber(argsObj.id); + const reassignToId = "reassign_to_id" in argsObj ? asNumber(argsObj.reassign_to_id) : undefined; + + await client.issueCategories.deleteIssueCategory(id, reassignToId); + + return { + content: [ + { + type: "text", + text: formatters.formatIssueCategoryDeleted(id), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + }; +} diff --git a/src/handlers/issue_statuses.ts b/src/handlers/issue_statuses.ts new file mode 100644 index 0000000..c5a41f3 --- /dev/null +++ b/src/handlers/issue_statuses.ts @@ -0,0 +1,39 @@ +import { HandlerContext, ToolResponse } from "./types.js"; +import * as formatters from "../formatters/index.js"; + +/** + * Creates handlers for issue status-related operations + */ +export function createIssueStatusesHandlers(context: HandlerContext) { + const { client } = context; + + return { + /** + * Lists all issue statuses + */ + list_issue_statuses: async (): Promise => { + try { + const response = await client.issueStatuses.getIssueStatuses(); + return { + content: [ + { + type: "text", + text: formatters.formatIssueStatuses(response.issue_statuses), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + }; +} diff --git a/src/handlers/issues.ts b/src/handlers/issues.ts index ca71569..88a3c9c 100644 --- a/src/handlers/issues.ts +++ b/src/handlers/issues.ts @@ -268,6 +268,12 @@ export function createIssuesHandlers(context: HandlerContext) { if ('start_date' in argsObj) updateParams.start_date = String(argsObj.start_date); if ('due_date' in argsObj) updateParams.due_date = String(argsObj.due_date); + // Validate that at least one field is being updated (besides id) + const hasUpdateFields = Object.keys(updateParams).length > 0; + if (!hasUpdateFields) { + throw new ValidationError("At least one field must be provided to update (e.g., notes, subject, status_id, etc.)"); + } + await client.issues.updateIssue(id, updateParams); return { @@ -280,6 +286,35 @@ export function createIssuesHandlers(context: HandlerContext) { isError: false, }; } catch (error) { + // Handle Zod validation errors + if (error && typeof error === 'object' && 'code' in error && error.code === 'invalid_type') { + const zodError = error as { code: string; expected: string; received: string; path: unknown[]; message: string }; + const pathStr = zodError.path.length > 0 ? ` at path: ${zodError.path.join('.')}` : ''; + return { + content: [ + { + type: "text", + text: `Validation Error: ${zodError.message}${pathStr}. Expected ${zodError.expected}, received ${zodError.received}.`, + } + ], + isError: true, + }; + } + + // Handle ValidationError + if (error instanceof ValidationError) { + return { + content: [ + { + type: "text", + text: `Input Error: ${error.message}`, + } + ], + isError: true, + }; + } + + // Handle other errors return { content: [ { diff --git a/src/handlers/memberships.ts b/src/handlers/memberships.ts new file mode 100644 index 0000000..b3ccd81 --- /dev/null +++ b/src/handlers/memberships.ts @@ -0,0 +1,232 @@ +import { + HandlerContext, + ToolResponse, + ValidationError, + asNumber, + extractPaginationParams, +} from "./types.js"; +import * as formatters from "../formatters/index.js"; +import type { RedmineMembershipCreate, RedmineMembershipUpdate } from "../lib/types/index.js"; + +/** + * Creates handlers for membership-related operations + */ +export function createMembershipsHandlers(context: HandlerContext) { + const { client } = context; + + return { + /** + * Lists memberships for a project + */ + list_memberships: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + if (!("project_id" in argsObj)) { + throw new ValidationError("project_id is required"); + } + + const projectId = String(argsObj.project_id); + const { limit, offset } = extractPaginationParams(argsObj); + const response = await client.memberships.getMemberships(projectId, { limit, offset }); + + return { + content: [ + { + type: "text", + text: formatters.formatMemberships(response.memberships, response.total_count), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Shows a specific membership + */ + show_membership: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + if (!("id" in argsObj)) { + throw new ValidationError("id is required"); + } + + const id = asNumber(argsObj.id); + const response = await client.memberships.getMembership(id); + + return { + content: [ + { + type: "text", + text: formatters.formatMembership(response.membership), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Creates a new membership + */ + create_membership: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + if (!("project_id" in argsObj)) { + throw new ValidationError("project_id is required"); + } + if (!("user_id" in argsObj)) { + throw new ValidationError("user_id is required"); + } + if (!("role_ids" in argsObj) || !Array.isArray(argsObj.role_ids)) { + throw new ValidationError("role_ids is required and must be an array"); + } + + const projectId = String(argsObj.project_id); + const membershipData: RedmineMembershipCreate = { + user_id: asNumber(argsObj.user_id), + role_ids: (argsObj.role_ids as unknown[]).map((id) => asNumber(id)), + }; + + const response = await client.memberships.createMembership(projectId, membershipData); + + return { + content: [ + { + type: "text", + text: formatters.formatMembershipResult(response.membership, "created"), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Updates a membership + */ + update_membership: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + if (!("id" in argsObj)) { + throw new ValidationError("id is required"); + } + if (!("role_ids" in argsObj) || !Array.isArray(argsObj.role_ids)) { + throw new ValidationError("role_ids is required and must be an array"); + } + + const id = asNumber(argsObj.id); + const membershipData: RedmineMembershipUpdate = { + role_ids: (argsObj.role_ids as unknown[]).map((roleId) => asNumber(roleId)), + }; + + await client.memberships.updateMembership(id, membershipData); + + return { + content: [ + { + type: "text", + text: formatters.formatMembershipUpdated(id), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Deletes a membership + */ + delete_membership: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + if (!("id" in argsObj)) { + throw new ValidationError("id is required"); + } + + const id = asNumber(argsObj.id); + await client.memberships.deleteMembership(id); + + return { + content: [ + { + type: "text", + text: formatters.formatMembershipDeleted(id), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + }; +} diff --git a/src/handlers/roles.ts b/src/handlers/roles.ts new file mode 100644 index 0000000..241b933 --- /dev/null +++ b/src/handlers/roles.ts @@ -0,0 +1,84 @@ +import { + HandlerContext, + ToolResponse, + ValidationError, + asNumber, +} from "./types.js"; +import * as formatters from "../formatters/index.js"; + +/** + * Creates handlers for role-related operations + */ +export function createRolesHandlers(context: HandlerContext) { + const { client } = context; + + return { + /** + * Lists all roles + */ + list_roles: async (): Promise => { + try { + const response = await client.roles.getRoles(); + + return { + content: [ + { + type: "text", + text: formatters.formatRoles(response.roles), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Shows a specific role with permissions + */ + show_role: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + if (!("id" in argsObj)) { + throw new ValidationError("id is required"); + } + + const id = asNumber(argsObj.id); + const response = await client.roles.getRole(id); + + return { + content: [ + { + type: "text", + text: formatters.formatRole(response.role), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + }; +} diff --git a/src/handlers/trackers.ts b/src/handlers/trackers.ts new file mode 100644 index 0000000..52c3a58 --- /dev/null +++ b/src/handlers/trackers.ts @@ -0,0 +1,39 @@ +import { HandlerContext, ToolResponse } from "./types.js"; +import * as formatters from "../formatters/index.js"; + +/** + * Creates handlers for tracker-related operations + */ +export function createTrackersHandlers(context: HandlerContext) { + const { client } = context; + + return { + /** + * Lists all trackers + */ + list_trackers: async (): Promise => { + try { + const response = await client.trackers.getTrackers(); + return { + content: [ + { + type: "text", + text: formatters.formatTrackers(response.trackers), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + }; +} diff --git a/src/handlers/versions.ts b/src/handlers/versions.ts new file mode 100644 index 0000000..5ebce7a --- /dev/null +++ b/src/handlers/versions.ts @@ -0,0 +1,256 @@ +import { + HandlerContext, + ToolResponse, + ValidationError, + asNumber, +} from "./types.js"; +import * as formatters from "../formatters/index.js"; +import type { RedmineVersionCreate, RedmineVersionUpdate } from "../lib/types/index.js"; + +/** + * Creates handlers for version-related operations + */ +export function createVersionsHandlers(context: HandlerContext) { + const { client } = context; + + return { + /** + * Lists versions for a project + */ + list_versions: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + if (!("project_id" in argsObj)) { + throw new ValidationError("project_id is required"); + } + + const projectId = String(argsObj.project_id); + const response = await client.versions.getVersions(projectId); + + return { + content: [ + { + type: "text", + text: formatters.formatVersions(response.versions), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Shows a specific version + */ + show_version: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + if (!("id" in argsObj)) { + throw new ValidationError("id is required"); + } + + const id = asNumber(argsObj.id); + const response = await client.versions.getVersion(id); + + return { + content: [ + { + type: "text", + text: formatters.formatVersion(response.version), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Creates a new version + */ + create_version: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + if (!("project_id" in argsObj)) { + throw new ValidationError("project_id is required"); + } + if (!("name" in argsObj)) { + throw new ValidationError("name is required"); + } + + const projectId = String(argsObj.project_id); + const versionData: RedmineVersionCreate = { + name: String(argsObj.name), + }; + + if ("status" in argsObj) { + versionData.status = String(argsObj.status) as "open" | "locked" | "closed"; + } + if ("sharing" in argsObj) { + versionData.sharing = String(argsObj.sharing) as "none" | "descendants" | "hierarchy" | "tree" | "system"; + } + if ("due_date" in argsObj) { + versionData.due_date = String(argsObj.due_date); + } + if ("description" in argsObj) { + versionData.description = String(argsObj.description); + } + if ("wiki_page_title" in argsObj) { + versionData.wiki_page_title = String(argsObj.wiki_page_title); + } + + const response = await client.versions.createVersion(projectId, versionData); + + return { + content: [ + { + type: "text", + text: formatters.formatVersionResult(response.version, "created"), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Updates a version + */ + update_version: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + if (!("id" in argsObj)) { + throw new ValidationError("id is required"); + } + + const id = asNumber(argsObj.id); + const versionData: RedmineVersionUpdate = {}; + + if ("name" in argsObj) { + versionData.name = String(argsObj.name); + } + if ("status" in argsObj) { + versionData.status = String(argsObj.status) as "open" | "locked" | "closed"; + } + if ("sharing" in argsObj) { + versionData.sharing = String(argsObj.sharing) as "none" | "descendants" | "hierarchy" | "tree" | "system"; + } + if ("due_date" in argsObj) { + versionData.due_date = String(argsObj.due_date); + } + if ("description" in argsObj) { + versionData.description = String(argsObj.description); + } + if ("wiki_page_title" in argsObj) { + versionData.wiki_page_title = String(argsObj.wiki_page_title); + } + + const response = await client.versions.updateVersion(id, versionData); + + return { + content: [ + { + type: "text", + text: formatters.formatVersionResult(response.version, "updated"), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + + /** + * Deletes a version + */ + delete_version: async (args: unknown): Promise => { + try { + if (typeof args !== "object" || args === null) { + throw new ValidationError("Arguments must be an object"); + } + + const argsObj = args as Record; + if (!("id" in argsObj)) { + throw new ValidationError("id is required"); + } + + const id = asNumber(argsObj.id); + await client.versions.deleteVersion(id); + + return { + content: [ + { + type: "text", + text: formatters.formatVersionDeleted(id), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + }; +} diff --git a/src/lib/client/attachments.ts b/src/lib/client/attachments.ts new file mode 100644 index 0000000..2173418 --- /dev/null +++ b/src/lib/client/attachments.ts @@ -0,0 +1,112 @@ +import { BaseClient } from "./base.js"; +import { + RedmineAttachment, + RedmineAttachmentResponse, + RedmineUploadResponse, + RedmineAttachmentUpdate, +} from "../types/attachments/index.js"; +import config from "../config.js"; + +/** + * Redmine Attachments API Client + */ +export class AttachmentsClient extends BaseClient { + /** + * Get a specific attachment by ID + * GET /attachments/:id.json + */ + async getAttachment(id: number): Promise { + return await this.performRequest( + `attachments/${id}.json` + ); + } + + /** + * Update an attachment (filename and/or description) + * PATCH /attachments/:id.json + * Available since Redmine 3.4.0 + */ + async updateAttachment( + id: number, + data: RedmineAttachmentUpdate + ): Promise { + await this.performRequest(`attachments/${id}.json`, { + method: "PATCH", + body: JSON.stringify({ attachment: data }), + }); + } + + /** + * Delete an attachment + * DELETE /attachments/:id.json + */ + async deleteAttachment(id: number): Promise { + await this.performRequest(`attachments/${id}.json`, { + method: "DELETE", + }); + } + + /** + * Upload a file to Redmine + * POST /uploads.json + * Returns a token that can be used to attach the file to an issue + * + * @param filename - The name of the file + * @param fileContent - The file content as base64 string or Buffer + * @returns Upload response with token + */ + async uploadFile( + filename: string, + fileContent: string | Buffer + ): Promise { + const url = new URL(`uploads.json?filename=${encodeURIComponent(filename)}`, config.redmine.host); + + // Convert base64 string to Buffer if needed + let body: Buffer; + if (typeof fileContent === "string") { + body = Buffer.from(fileContent, "base64"); + } else { + body = fileContent; + } + + const response = await fetch(url.toString(), { + method: "POST", + headers: { + "X-Redmine-API-Key": config.redmine.apiKey, + "Content-Type": "application/octet-stream", + }, + body: body, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Upload failed: ${response.status} ${response.statusText} - ${errorText}` + ); + } + + return await response.json() as RedmineUploadResponse; + } + + /** + * Download an attachment file + * GET /attachments/download/:id/:filename + * Returns the file content as Buffer + */ + async downloadAttachment(attachment: RedmineAttachment): Promise { + const response = await fetch(attachment.content_url, { + headers: { + "X-Redmine-API-Key": config.redmine.apiKey, + }, + }); + + if (!response.ok) { + throw new Error( + `Download failed: ${response.status} ${response.statusText}` + ); + } + + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); + } +} diff --git a/src/lib/client/enumerations.ts b/src/lib/client/enumerations.ts new file mode 100644 index 0000000..b1970b5 --- /dev/null +++ b/src/lib/client/enumerations.ts @@ -0,0 +1,38 @@ +import { BaseClient } from "./base.js"; +import { + RedmineIssuePrioritiesResponse, + RedmineTimeEntryActivitiesResponse, + RedmineDocumentCategoriesResponse, +} from "../types/index.js"; + +export class EnumerationsClient extends BaseClient { + /** + * Get list of issue priorities + * GET /enumerations/issue_priorities.json + */ + async getIssuePriorities(): Promise { + return await this.performRequest( + "enumerations/issue_priorities.json" + ); + } + + /** + * Get list of time entry activities + * GET /enumerations/time_entry_activities.json + */ + async getTimeEntryActivities(): Promise { + return await this.performRequest( + "enumerations/time_entry_activities.json" + ); + } + + /** + * Get list of document categories + * GET /enumerations/document_categories.json + */ + async getDocumentCategories(): Promise { + return await this.performRequest( + "enumerations/document_categories.json" + ); + } +} diff --git a/src/lib/client/index.ts b/src/lib/client/index.ts index abf9807..9689988 100644 --- a/src/lib/client/index.ts +++ b/src/lib/client/index.ts @@ -2,26 +2,50 @@ import { IssuesClient } from "./issues.js"; import { ProjectsClient } from "./projects.js"; import { TimeEntriesClient } from "./time_entries.js"; import { UsersClient } from "./users.js"; +import { IssueStatusesClient } from "./issue_statuses.js"; +import { TrackersClient } from "./trackers.js"; +import { EnumerationsClient } from "./enumerations.js"; +import { VersionsClient } from "./versions.js"; +import { MembershipsClient } from "./memberships.js"; +import { RolesClient } from "./roles.js"; +import { IssueCategoriesClient } from "./issue_categories.js"; +import { AttachmentsClient } from "./attachments.js"; import { RedmineApiError } from "./base.js"; /** - * Redmine API クライアント + * Redmine API Client */ export class RedmineClient { public readonly issues: IssuesClient; public readonly projects: ProjectsClient; public readonly timeEntries: TimeEntriesClient; public readonly users: UsersClient; + public readonly issueStatuses: IssueStatusesClient; + public readonly trackers: TrackersClient; + public readonly enumerations: EnumerationsClient; + public readonly versions: VersionsClient; + public readonly memberships: MembershipsClient; + public readonly roles: RolesClient; + public readonly issueCategories: IssueCategoriesClient; + public readonly attachments: AttachmentsClient; constructor() { this.issues = new IssuesClient(); this.projects = new ProjectsClient(); this.timeEntries = new TimeEntriesClient(); this.users = new UsersClient(); + this.issueStatuses = new IssueStatusesClient(); + this.trackers = new TrackersClient(); + this.enumerations = new EnumerationsClient(); + this.versions = new VersionsClient(); + this.memberships = new MembershipsClient(); + this.roles = new RolesClient(); + this.issueCategories = new IssueCategoriesClient(); + this.attachments = new AttachmentsClient(); } } -// クライアントのシングルトンインスタンス +// Singleton client instance export const redmineClient = new RedmineClient(); export { RedmineApiError }; \ No newline at end of file diff --git a/src/lib/client/issue_categories.ts b/src/lib/client/issue_categories.ts new file mode 100644 index 0000000..6ff60a6 --- /dev/null +++ b/src/lib/client/issue_categories.ts @@ -0,0 +1,87 @@ +import { BaseClient } from "./base.js"; +import { + RedmineIssueCategory, + RedmineIssueCategoriesResponse, + RedmineIssueCategoryCreate, + RedmineIssueCategoryUpdate, +} from "../types/index.js"; +import { RedmineIssueCategorySchema } from "../types/issue_categories/schema.js"; + +export class IssueCategoriesClient extends BaseClient { + /** + * Get list of issue categories for a project + * GET /projects/:project_id/issue_categories.json + */ + async getIssueCategories( + projectId: number | string + ): Promise { + return await this.performRequest( + `projects/${projectId}/issue_categories.json` + ); + } + + /** + * Get a single issue category by ID + * GET /issue_categories/:id.json + */ + async getIssueCategory(id: number): Promise<{ issue_category: RedmineIssueCategory }> { + const response = await this.performRequest<{ issue_category: RedmineIssueCategory }>( + `issue_categories/${id}.json` + ); + return { + issue_category: RedmineIssueCategorySchema.parse(response.issue_category), + }; + } + + /** + * Create a new issue category for a project + * POST /projects/:project_id/issue_categories.json + */ + async createIssueCategory( + projectId: number | string, + issueCategory: RedmineIssueCategoryCreate + ): Promise<{ issue_category: RedmineIssueCategory }> { + const response = await this.performRequest<{ issue_category: RedmineIssueCategory }>( + `projects/${projectId}/issue_categories.json`, + { + method: "POST", + body: JSON.stringify({ issue_category: issueCategory }), + } + ); + return { + issue_category: RedmineIssueCategorySchema.parse(response.issue_category), + }; + } + + /** + * Update an issue category + * PUT /issue_categories/:id.json + */ + async updateIssueCategory( + id: number, + issueCategory: RedmineIssueCategoryUpdate + ): Promise<{ issue_category: RedmineIssueCategory }> { + const response = await this.performRequest<{ issue_category: RedmineIssueCategory }>( + `issue_categories/${id}.json`, + { + method: "PUT", + body: JSON.stringify({ issue_category: issueCategory }), + } + ); + return { + issue_category: RedmineIssueCategorySchema.parse(response.issue_category), + }; + } + + /** + * Delete an issue category + * DELETE /issue_categories/:id.json + * @param reassignToId Optional category ID to reassign issues to before deletion + */ + async deleteIssueCategory(id: number, reassignToId?: number): Promise { + const query = reassignToId ? `?reassign_to_id=${reassignToId}` : ""; + await this.performRequest(`issue_categories/${id}.json${query}`, { + method: "DELETE", + }); + } +} diff --git a/src/lib/client/issue_statuses.ts b/src/lib/client/issue_statuses.ts new file mode 100644 index 0000000..6b09dd7 --- /dev/null +++ b/src/lib/client/issue_statuses.ts @@ -0,0 +1,14 @@ +import { BaseClient } from "./base.js"; +import { RedmineIssueStatusesResponse } from "../types/index.js"; + +export class IssueStatusesClient extends BaseClient { + /** + * Get list of all issue statuses + * GET /issue_statuses.json + */ + async getIssueStatuses(): Promise { + return await this.performRequest( + "issue_statuses.json" + ); + } +} diff --git a/src/lib/client/issues.ts b/src/lib/client/issues.ts index bd60552..5eef827 100644 --- a/src/lib/client/issues.ts +++ b/src/lib/client/issues.ts @@ -107,22 +107,19 @@ export class IssuesClient extends BaseClient { * * @param id Issue ID to update * @param issue Update parameters - * @returns Promise with updated issue + * @returns Promise that resolves when the issue is updated */ async updateIssue( id: number, issue: RedmineIssueUpdate - ): Promise<{ issue: RedmineIssue }> { - const response = await this.performRequest<{ issue: RedmineIssue }>( + ): Promise { + await this.performRequest( `issues/${id}.json`, { method: "PUT", body: JSON.stringify({ issue }), } ); - return { - issue: RedmineIssueSchema.parse(response.issue), - }; } /** diff --git a/src/lib/client/memberships.ts b/src/lib/client/memberships.ts new file mode 100644 index 0000000..259c204 --- /dev/null +++ b/src/lib/client/memberships.ts @@ -0,0 +1,81 @@ +import { BaseClient } from "./base.js"; +import { + RedmineMembership, + RedmineMembershipsResponse, + RedmineMembershipCreate, + RedmineMembershipUpdate, +} from "../types/index.js"; +import { RedmineMembershipSchema } from "../types/memberships/schema.js"; + +export class MembershipsClient extends BaseClient { + /** + * Get list of memberships for a project + * GET /projects/:project_id/memberships.json + */ + async getMemberships( + projectId: number | string, + params?: { offset?: number; limit?: number } + ): Promise { + const query = params ? this.encodeQueryParams(params) : ""; + return await this.performRequest( + `projects/${projectId}/memberships.json${query ? `?${query}` : ""}` + ); + } + + /** + * Get a single membership by ID + * GET /memberships/:id.json + */ + async getMembership(id: number): Promise<{ membership: RedmineMembership }> { + const response = await this.performRequest<{ membership: RedmineMembership }>( + `memberships/${id}.json` + ); + return { + membership: RedmineMembershipSchema.parse(response.membership), + }; + } + + /** + * Create a new membership for a project + * POST /projects/:project_id/memberships.json + */ + async createMembership( + projectId: number | string, + membership: RedmineMembershipCreate + ): Promise<{ membership: RedmineMembership }> { + const response = await this.performRequest<{ membership: RedmineMembership }>( + `projects/${projectId}/memberships.json`, + { + method: "POST", + body: JSON.stringify({ membership }), + } + ); + return { + membership: RedmineMembershipSchema.parse(response.membership), + }; + } + + /** + * Update a membership (roles only) + * PUT /memberships/:id.json + */ + async updateMembership( + id: number, + membership: RedmineMembershipUpdate + ): Promise { + await this.performRequest(`memberships/${id}.json`, { + method: "PUT", + body: JSON.stringify({ membership }), + }); + } + + /** + * Delete a membership + * DELETE /memberships/:id.json + */ + async deleteMembership(id: number): Promise { + await this.performRequest(`memberships/${id}.json`, { + method: "DELETE", + }); + } +} diff --git a/src/lib/client/roles.ts b/src/lib/client/roles.ts new file mode 100644 index 0000000..f5e0f27 --- /dev/null +++ b/src/lib/client/roles.ts @@ -0,0 +1,27 @@ +import { BaseClient } from "./base.js"; +import { RedmineRole, RedmineRolesResponse } from "../types/index.js"; +import { RedmineRoleSchema } from "../types/roles/schema.js"; + +export class RolesClient extends BaseClient { + /** + * Get list of all roles + * GET /roles.json + */ + async getRoles(): Promise { + return await this.performRequest("roles.json"); + } + + /** + * Get a single role by ID with permissions + * GET /roles/:id.json + * Available since Redmine 2.2.0 + */ + async getRole(id: number): Promise<{ role: RedmineRole }> { + const response = await this.performRequest<{ role: RedmineRole }>( + `roles/${id}.json` + ); + return { + role: RedmineRoleSchema.parse(response.role), + }; + } +} diff --git a/src/lib/client/trackers.ts b/src/lib/client/trackers.ts new file mode 100644 index 0000000..53be6bc --- /dev/null +++ b/src/lib/client/trackers.ts @@ -0,0 +1,14 @@ +import { BaseClient } from "./base.js"; +import { RedmineTrackersResponse } from "../types/index.js"; + +export class TrackersClient extends BaseClient { + /** + * Get list of all trackers + * GET /trackers.json + */ + async getTrackers(): Promise { + return await this.performRequest( + "trackers.json" + ); + } +} diff --git a/src/lib/client/versions.ts b/src/lib/client/versions.ts new file mode 100644 index 0000000..924c600 --- /dev/null +++ b/src/lib/client/versions.ts @@ -0,0 +1,83 @@ +import { BaseClient } from "./base.js"; +import { + RedmineVersion, + RedmineVersionsResponse, + RedmineVersionCreate, + RedmineVersionUpdate, +} from "../types/index.js"; +import { RedmineVersionSchema } from "../types/versions/schema.js"; + +export class VersionsClient extends BaseClient { + /** + * Get list of versions for a project + * GET /projects/:project_id/versions.json + */ + async getVersions(projectId: number | string): Promise { + return await this.performRequest( + `projects/${projectId}/versions.json` + ); + } + + /** + * Get a single version by ID + * GET /versions/:id.json + */ + async getVersion(id: number): Promise<{ version: RedmineVersion }> { + const response = await this.performRequest<{ version: RedmineVersion }>( + `versions/${id}.json` + ); + return { + version: RedmineVersionSchema.parse(response.version), + }; + } + + /** + * Create a new version for a project + * POST /projects/:project_id/versions.json + */ + async createVersion( + projectId: number | string, + version: RedmineVersionCreate + ): Promise<{ version: RedmineVersion }> { + const response = await this.performRequest<{ version: RedmineVersion }>( + `projects/${projectId}/versions.json`, + { + method: "POST", + body: JSON.stringify({ version }), + } + ); + return { + version: RedmineVersionSchema.parse(response.version), + }; + } + + /** + * Update a version + * PUT /versions/:id.json + */ + async updateVersion( + id: number, + version: RedmineVersionUpdate + ): Promise<{ version: RedmineVersion }> { + const response = await this.performRequest<{ version: RedmineVersion }>( + `versions/${id}.json`, + { + method: "PUT", + body: JSON.stringify({ version }), + } + ); + return { + version: RedmineVersionSchema.parse(response.version), + }; + } + + /** + * Delete a version + * DELETE /versions/:id.json + */ + async deleteVersion(id: number): Promise { + await this.performRequest(`versions/${id}.json`, { + method: "DELETE", + }); + } +} diff --git a/src/lib/types/attachments/index.ts b/src/lib/types/attachments/index.ts new file mode 100644 index 0000000..c41e45d --- /dev/null +++ b/src/lib/types/attachments/index.ts @@ -0,0 +1,2 @@ +export * from "./types.js"; +export * from "./schema.js"; diff --git a/src/lib/types/attachments/schema.ts b/src/lib/types/attachments/schema.ts new file mode 100644 index 0000000..508a8be --- /dev/null +++ b/src/lib/types/attachments/schema.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +/** + * Schema for Redmine attachment + */ +export const RedmineAttachmentSchema = z.object({ + id: z.number(), + filename: z.string(), + filesize: z.number(), + content_type: z.string(), + description: z.string().optional(), + content_url: z.string(), + thumbnail_url: z.string().optional(), + author: z.object({ + id: z.number(), + name: z.string(), + }), + created_on: z.string(), +}); + +/** + * Schema for upload response + */ +export const RedmineUploadResponseSchema = z.object({ + upload: z.object({ + token: z.string(), + }), +}); + +/** + * Schema for attachment upload (when attaching to issue) + */ +export const RedmineAttachmentUploadSchema = z.object({ + token: z.string(), + filename: z.string(), + content_type: z.string().optional(), + description: z.string().optional(), +}); + +/** + * Schema for single attachment response + */ +export const RedmineAttachmentResponseSchema = z.object({ + attachment: RedmineAttachmentSchema, +}); + +/** + * Schema for attachment update + */ +export const RedmineAttachmentUpdateSchema = z.object({ + filename: z.string().optional(), + description: z.string().optional(), +}); diff --git a/src/lib/types/attachments/types.ts b/src/lib/types/attachments/types.ts new file mode 100644 index 0000000..f33b108 --- /dev/null +++ b/src/lib/types/attachments/types.ts @@ -0,0 +1,53 @@ +// Attachment resource types + +/** + * Represents an attachment in Redmine + */ +export interface RedmineAttachment { + id: number; + filename: string; + filesize: number; + content_type: string; + description?: string; + content_url: string; + thumbnail_url?: string; + author: { + id: number; + name: string; + }; + created_on: string; +} + +/** + * Response from upload endpoint + */ +export interface RedmineUploadResponse { + upload: { + token: string; + }; +} + +/** + * Attachment to be included when creating/updating an issue + */ +export interface RedmineAttachmentUpload { + token: string; + filename: string; + content_type?: string; + description?: string; +} + +/** + * Response when getting a single attachment + */ +export interface RedmineAttachmentResponse { + attachment: RedmineAttachment; +} + +/** + * Parameters for updating an attachment + */ +export interface RedmineAttachmentUpdate { + filename?: string; + description?: string; +} diff --git a/src/lib/types/enumerations/index.ts b/src/lib/types/enumerations/index.ts new file mode 100644 index 0000000..c41e45d --- /dev/null +++ b/src/lib/types/enumerations/index.ts @@ -0,0 +1,2 @@ +export * from "./types.js"; +export * from "./schema.js"; diff --git a/src/lib/types/enumerations/schema.ts b/src/lib/types/enumerations/schema.ts new file mode 100644 index 0000000..90cdcfd --- /dev/null +++ b/src/lib/types/enumerations/schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const RedmineEnumerationSchema = z.object({ + id: z.number(), + name: z.string(), + is_default: z.boolean(), +}); + +export const RedmineIssuePrioritiesResponseSchema = z.object({ + issue_priorities: z.array(RedmineEnumerationSchema), +}); + +export const RedmineTimeEntryActivitiesResponseSchema = z.object({ + time_entry_activities: z.array(RedmineEnumerationSchema), +}); + +export const RedmineDocumentCategoriesResponseSchema = z.object({ + document_categories: z.array(RedmineEnumerationSchema), +}); diff --git a/src/lib/types/enumerations/types.ts b/src/lib/types/enumerations/types.ts new file mode 100644 index 0000000..1d12221 --- /dev/null +++ b/src/lib/types/enumerations/types.ts @@ -0,0 +1,20 @@ +/** + * Enumeration types (priorities, activities, document categories) + */ +export interface RedmineEnumeration { + id: number; + name: string; + is_default: boolean; +} + +export interface RedmineIssuePrioritiesResponse { + issue_priorities: RedmineEnumeration[]; +} + +export interface RedmineTimeEntryActivitiesResponse { + time_entry_activities: RedmineEnumeration[]; +} + +export interface RedmineDocumentCategoriesResponse { + document_categories: RedmineEnumeration[]; +} diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 92cfe81..790544e 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -1,8 +1,16 @@ -// 共通型のエクスポート +// Common types export * from "./common.js"; -// リソース固有の型のエクスポート +// Resource-specific types export * from "./issues/index.js"; export * from "./projects/index.js"; export * from "./time_entries/index.js"; -export * from "./users/index.js"; \ No newline at end of file +export * from "./users/index.js"; +export * from "./issue_statuses/index.js"; +export * from "./trackers/index.js"; +export * from "./enumerations/index.js"; +export * from "./versions/index.js"; +export * from "./memberships/index.js"; +export * from "./roles/index.js"; +export * from "./issue_categories/index.js"; +export * from "./attachments/index.js"; \ No newline at end of file diff --git a/src/lib/types/issue_categories/index.ts b/src/lib/types/issue_categories/index.ts new file mode 100644 index 0000000..c41e45d --- /dev/null +++ b/src/lib/types/issue_categories/index.ts @@ -0,0 +1,2 @@ +export * from "./types.js"; +export * from "./schema.js"; diff --git a/src/lib/types/issue_categories/schema.ts b/src/lib/types/issue_categories/schema.ts new file mode 100644 index 0000000..2ed42a1 --- /dev/null +++ b/src/lib/types/issue_categories/schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const RedmineIssueCategorySchema = z.object({ + id: z.number(), + project: z.object({ + id: z.number(), + name: z.string(), + }), + name: z.string(), + assigned_to: z.object({ + id: z.number(), + name: z.string(), + }).optional(), +}); + +export const RedmineIssueCategoriesResponseSchema = z.object({ + issue_categories: z.array(RedmineIssueCategorySchema), +}); diff --git a/src/lib/types/issue_categories/types.ts b/src/lib/types/issue_categories/types.ts new file mode 100644 index 0000000..6dc142a --- /dev/null +++ b/src/lib/types/issue_categories/types.ts @@ -0,0 +1,26 @@ +/** + * Issue Category type definitions + */ +export interface RedmineIssueCategory { + id: number; + project: { + id: number; + name: string; + }; + name: string; + assigned_to?: { + id: number; + name: string; + }; +} + +export interface RedmineIssueCategoriesResponse { + issue_categories: RedmineIssueCategory[]; +} + +export interface RedmineIssueCategoryCreate { + name: string; + assigned_to_id?: number; +} + +export type RedmineIssueCategoryUpdate = Partial; diff --git a/src/lib/types/issue_statuses/index.ts b/src/lib/types/issue_statuses/index.ts new file mode 100644 index 0000000..c41e45d --- /dev/null +++ b/src/lib/types/issue_statuses/index.ts @@ -0,0 +1,2 @@ +export * from "./types.js"; +export * from "./schema.js"; diff --git a/src/lib/types/issue_statuses/schema.ts b/src/lib/types/issue_statuses/schema.ts new file mode 100644 index 0000000..6a61648 --- /dev/null +++ b/src/lib/types/issue_statuses/schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const RedmineIssueStatusSchema = z.object({ + id: z.number(), + name: z.string(), + is_closed: z.boolean(), +}); + +export const RedmineIssueStatusesResponseSchema = z.object({ + issue_statuses: z.array(RedmineIssueStatusSchema), +}); diff --git a/src/lib/types/issue_statuses/types.ts b/src/lib/types/issue_statuses/types.ts new file mode 100644 index 0000000..d1bebcf --- /dev/null +++ b/src/lib/types/issue_statuses/types.ts @@ -0,0 +1,12 @@ +/** + * Issue Status type definition + */ +export interface RedmineIssueStatus { + id: number; + name: string; + is_closed: boolean; +} + +export interface RedmineIssueStatusesResponse { + issue_statuses: RedmineIssueStatus[]; +} diff --git a/src/lib/types/issues/types.ts b/src/lib/types/issues/types.ts index 303db64..969a1d3 100644 --- a/src/lib/types/issues/types.ts +++ b/src/lib/types/issues/types.ts @@ -122,6 +122,20 @@ export interface RedmineIssue { }[]; }[]; // children?: RedmineIssue[]; // If 'children' is included. Be careful with recursion. + attachments?: { + id: number; + filename: string; + filesize: number; + content_type: string; + description?: string; + content_url: string; + thumbnail_url?: string; + author: { + id: number; + name: string; + }; + created_on: string; + }[]; // Added based on the reference document for list_project_statuses allowed_statuses?: { @@ -131,6 +145,16 @@ export interface RedmineIssue { }[]; } +/** + * Upload attachment for issue creation/update + */ +export interface RedmineIssueUpload { + token: string; + filename: string; + content_type?: string; + description?: string; +} + export interface RedmineIssueCreate { project_id: number; tracker_id?: number; @@ -151,6 +175,7 @@ export interface RedmineIssueCreate { estimated_hours?: number; start_date?: string; // YYYY-MM-DD due_date?: string; // YYYY-MM-DD + uploads?: RedmineIssueUpload[]; // Attachments to add } export interface RedmineIssueUpdate extends Partial { diff --git a/src/lib/types/memberships/index.ts b/src/lib/types/memberships/index.ts new file mode 100644 index 0000000..c41e45d --- /dev/null +++ b/src/lib/types/memberships/index.ts @@ -0,0 +1,2 @@ +export * from "./types.js"; +export * from "./schema.js"; diff --git a/src/lib/types/memberships/schema.ts b/src/lib/types/memberships/schema.ts new file mode 100644 index 0000000..f3404db --- /dev/null +++ b/src/lib/types/memberships/schema.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +export const RedmineMembershipRoleSchema = z.object({ + id: z.number(), + name: z.string(), + inherited: z.boolean().optional(), +}); + +export const RedmineMembershipSchema = z.object({ + id: z.number(), + project: z.object({ + id: z.number(), + name: z.string(), + }), + user: z.object({ + id: z.number(), + name: z.string(), + }).optional(), + group: z.object({ + id: z.number(), + name: z.string(), + }).optional(), + roles: z.array(RedmineMembershipRoleSchema), +}); + +export const RedmineMembershipsResponseSchema = z.object({ + memberships: z.array(RedmineMembershipSchema), + total_count: z.number(), + offset: z.number(), + limit: z.number(), +}); diff --git a/src/lib/types/memberships/types.ts b/src/lib/types/memberships/types.ts new file mode 100644 index 0000000..787f723 --- /dev/null +++ b/src/lib/types/memberships/types.ts @@ -0,0 +1,41 @@ +/** + * Project Membership type definitions + */ +export interface RedmineMembershipRole { + id: number; + name: string; + inherited?: boolean; +} + +export interface RedmineMembership { + id: number; + project: { + id: number; + name: string; + }; + user?: { + id: number; + name: string; + }; + group?: { + id: number; + name: string; + }; + roles: RedmineMembershipRole[]; +} + +export interface RedmineMembershipsResponse { + memberships: RedmineMembership[]; + total_count: number; + offset: number; + limit: number; +} + +export interface RedmineMembershipCreate { + user_id: number; + role_ids: number[]; +} + +export interface RedmineMembershipUpdate { + role_ids: number[]; +} diff --git a/src/lib/types/roles/index.ts b/src/lib/types/roles/index.ts new file mode 100644 index 0000000..c41e45d --- /dev/null +++ b/src/lib/types/roles/index.ts @@ -0,0 +1,2 @@ +export * from "./types.js"; +export * from "./schema.js"; diff --git a/src/lib/types/roles/schema.ts b/src/lib/types/roles/schema.ts new file mode 100644 index 0000000..07ceea8 --- /dev/null +++ b/src/lib/types/roles/schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const RedmineRoleSchema = z.object({ + id: z.number(), + name: z.string(), + assignable: z.boolean().optional(), + issues_visibility: z.string().optional(), + time_entries_visibility: z.string().optional(), + users_visibility: z.string().optional(), + permissions: z.array(z.string()).optional(), +}); + +export const RedmineRolesResponseSchema = z.object({ + roles: z.array(RedmineRoleSchema), +}); diff --git a/src/lib/types/roles/types.ts b/src/lib/types/roles/types.ts new file mode 100644 index 0000000..2394111 --- /dev/null +++ b/src/lib/types/roles/types.ts @@ -0,0 +1,16 @@ +/** + * Role type definitions + */ +export interface RedmineRole { + id: number; + name: string; + assignable?: boolean; + issues_visibility?: string; + time_entries_visibility?: string; + users_visibility?: string; + permissions?: string[]; +} + +export interface RedmineRolesResponse { + roles: RedmineRole[]; +} diff --git a/src/lib/types/trackers/index.ts b/src/lib/types/trackers/index.ts new file mode 100644 index 0000000..c41e45d --- /dev/null +++ b/src/lib/types/trackers/index.ts @@ -0,0 +1,2 @@ +export * from "./types.js"; +export * from "./schema.js"; diff --git a/src/lib/types/trackers/schema.ts b/src/lib/types/trackers/schema.ts new file mode 100644 index 0000000..db20e3e --- /dev/null +++ b/src/lib/types/trackers/schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const RedmineTrackerSchema = z.object({ + id: z.number(), + name: z.string(), + default_status: z.object({ + id: z.number(), + name: z.string(), + }).optional(), + description: z.string().optional(), + enabled_standard_fields: z.array(z.string()).optional(), +}); + +export const RedmineTrackersResponseSchema = z.object({ + trackers: z.array(RedmineTrackerSchema), +}); diff --git a/src/lib/types/trackers/types.ts b/src/lib/types/trackers/types.ts new file mode 100644 index 0000000..1f11fb2 --- /dev/null +++ b/src/lib/types/trackers/types.ts @@ -0,0 +1,17 @@ +/** + * Tracker type definition + */ +export interface RedmineTracker { + id: number; + name: string; + default_status?: { + id: number; + name: string; + }; + description?: string; + enabled_standard_fields?: string[]; +} + +export interface RedmineTrackersResponse { + trackers: RedmineTracker[]; +} diff --git a/src/lib/types/versions/index.ts b/src/lib/types/versions/index.ts new file mode 100644 index 0000000..c41e45d --- /dev/null +++ b/src/lib/types/versions/index.ts @@ -0,0 +1,2 @@ +export * from "./types.js"; +export * from "./schema.js"; diff --git a/src/lib/types/versions/schema.ts b/src/lib/types/versions/schema.ts new file mode 100644 index 0000000..5d9dcc5 --- /dev/null +++ b/src/lib/types/versions/schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export const RedmineVersionSchema = z.object({ + id: z.number(), + project: z.object({ + id: z.number(), + name: z.string(), + }), + name: z.string(), + description: z.string().optional(), + status: z.enum(["open", "locked", "closed"]), + due_date: z.string().nullable().optional(), + sharing: z.enum(["none", "descendants", "hierarchy", "tree", "system"]), + wiki_page_title: z.string().optional(), + estimated_hours: z.number().optional(), + spent_hours: z.number().optional(), + created_on: z.string(), + updated_on: z.string(), +}); + +export const RedmineVersionsResponseSchema = z.object({ + versions: z.array(RedmineVersionSchema), + total_count: z.number(), +}); diff --git a/src/lib/types/versions/types.ts b/src/lib/types/versions/types.ts new file mode 100644 index 0000000..b949574 --- /dev/null +++ b/src/lib/types/versions/types.ts @@ -0,0 +1,36 @@ +/** + * Version type definitions + */ +export interface RedmineVersion { + id: number; + project: { + id: number; + name: string; + }; + name: string; + description?: string; + status: "open" | "locked" | "closed"; + due_date?: string | null; + sharing: "none" | "descendants" | "hierarchy" | "tree" | "system"; + wiki_page_title?: string; + estimated_hours?: number; + spent_hours?: number; + created_on: string; + updated_on: string; +} + +export interface RedmineVersionsResponse { + versions: RedmineVersion[]; + total_count: number; +} + +export interface RedmineVersionCreate { + name: string; + status?: "open" | "locked" | "closed"; + sharing?: "none" | "descendants" | "hierarchy" | "tree" | "system"; + due_date?: string; + description?: string; + wiki_page_title?: string; +} + +export type RedmineVersionUpdate = Partial; diff --git a/src/tools/attachments.ts b/src/tools/attachments.ts new file mode 100644 index 0000000..2b70575 --- /dev/null +++ b/src/tools/attachments.ts @@ -0,0 +1,146 @@ +import { Tool } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Upload a file to Redmine + * First step: upload file to get a token + */ +export const ATTACHMENT_UPLOAD_TOOL: Tool = { + name: "upload_file", + description: + "Upload a file to Redmine. " + + "Returns an upload token that can be used to attach the file to an issue. " + + "Use the returned token with create_issue or update_issue to attach the file. " + + "Available since Redmine 1.4", + inputSchema: { + type: "object", + properties: { + filename: { + type: "string", + description: "Name of the file (e.g., 'screenshot.png', 'document.pdf')", + }, + content_base64: { + type: "string", + description: "File content encoded as base64 string", + }, + }, + required: ["filename", "content_base64"], + }, +}; + +/** + * Get attachment details + */ +export const ATTACHMENT_GET_TOOL: Tool = { + name: "get_attachment", + description: + "Get details of a specific attachment by ID. " + + "Returns filename, size, content type, author, and download URL. " + + "Available since Redmine 1.3", + inputSchema: { + type: "object", + properties: { + id: { + type: "number", + description: "Attachment ID", + }, + }, + required: ["id"], + }, +}; + +/** + * Update attachment (filename and/or description) + */ +export const ATTACHMENT_UPDATE_TOOL: Tool = { + name: "update_attachment", + description: + "Update an attachment's filename or description. " + + "Only specified fields will be changed. " + + "Available since Redmine 3.4", + inputSchema: { + type: "object", + properties: { + id: { + type: "number", + description: "Attachment ID to update", + }, + filename: { + type: "string", + description: "New filename for the attachment", + }, + description: { + type: "string", + description: "New description for the attachment", + }, + }, + required: ["id"], + }, +}; + +/** + * Delete an attachment + */ +export const ATTACHMENT_DELETE_TOOL: Tool = { + name: "delete_attachment", + description: + "Delete an attachment permanently. " + + "This action cannot be undone. " + + "Available since Redmine 1.3", + inputSchema: { + type: "object", + properties: { + id: { + type: "number", + description: "Attachment ID to delete", + }, + }, + required: ["id"], + }, +}; + +/** + * Download attachment content + */ +export const ATTACHMENT_DOWNLOAD_TOOL: Tool = { + name: "download_attachment", + description: + "Download an attachment file content. " + + "Returns the file content as base64 encoded string. " + + "Available since Redmine 1.3", + inputSchema: { + type: "object", + properties: { + id: { + type: "number", + description: "Attachment ID to download", + }, + }, + required: ["id"], + }, +}; + +/** + * Upload a file from local filesystem path + */ +export const ATTACHMENT_UPLOAD_FROM_PATH_TOOL: Tool = { + name: "upload_file_from_path", + description: + "Upload a file from local file system path to Redmine. " + + "Returns an upload token that can be used to attach the file to an issue. " + + "Use the returned token with create_issue or update_issue to attach the file. " + + "Available since Redmine 1.4", + inputSchema: { + type: "object", + properties: { + file_path: { + type: "string", + description: "Absolute path to the file on local file system (e.g., 'C:\\Users\\name\\screenshot.png' or '/home/user/document.pdf')", + }, + filename: { + type: "string", + description: "Optional: Override the filename for the attachment (default: use original filename from path)", + }, + }, + required: ["file_path"], + }, +}; diff --git a/src/tools/enumerations.ts b/src/tools/enumerations.ts new file mode 100644 index 0000000..58e8997 --- /dev/null +++ b/src/tools/enumerations.ts @@ -0,0 +1,40 @@ +import { Tool } from "@modelcontextprotocol/sdk/types.js"; + +// List issue priorities tool +export const ENUMERATION_ISSUE_PRIORITIES_TOOL: Tool = { + name: "list_issue_priorities", + description: + "Get list of issue priorities. " + + "Returns priority ID, name, and default flag. " + + "Available since Redmine 2.2", + inputSchema: { + type: "object", + properties: {}, + }, +}; + +// List time entry activities tool +export const ENUMERATION_TIME_ENTRY_ACTIVITIES_TOOL: Tool = { + name: "list_time_entry_activities", + description: + "Get list of time entry activities. " + + "Returns activity ID, name, and default flag. " + + "Available since Redmine 2.2", + inputSchema: { + type: "object", + properties: {}, + }, +}; + +// List document categories tool +export const ENUMERATION_DOCUMENT_CATEGORIES_TOOL: Tool = { + name: "list_document_categories", + description: + "Get list of document categories. " + + "Returns category ID, name, and default flag. " + + "Available since Redmine 2.2", + inputSchema: { + type: "object", + properties: {}, + }, +}; diff --git a/src/tools/index.ts b/src/tools/index.ts index 76a71cf..537e08f 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -18,7 +18,7 @@ export { PROJECT_ARCHIVE_TOOL, PROJECT_UNARCHIVE_TOOL, PROJECT_DELETE_TOOL, - PROJECT_LIST_STATUSES_TOOL, // Added new tool + PROJECT_LIST_STATUSES_TOOL, } from "./projects.js"; // Time entry tools @@ -39,3 +39,59 @@ export { USER_UPDATE_TOOL, USER_DELETE_TOOL, } from "./users.js"; + +// Issue status tools +export { ISSUE_STATUS_LIST_TOOL } from "./issue_statuses.js"; + +// Tracker tools +export { TRACKER_LIST_TOOL } from "./trackers.js"; + +// Enumeration tools +export { + ENUMERATION_ISSUE_PRIORITIES_TOOL, + ENUMERATION_TIME_ENTRY_ACTIVITIES_TOOL, + ENUMERATION_DOCUMENT_CATEGORIES_TOOL, +} from "./enumerations.js"; + +// Version tools +export { + VERSION_LIST_TOOL, + VERSION_SHOW_TOOL, + VERSION_CREATE_TOOL, + VERSION_UPDATE_TOOL, + VERSION_DELETE_TOOL, +} from "./versions.js"; + +// Membership tools +export { + MEMBERSHIP_LIST_TOOL, + MEMBERSHIP_SHOW_TOOL, + MEMBERSHIP_CREATE_TOOL, + MEMBERSHIP_UPDATE_TOOL, + MEMBERSHIP_DELETE_TOOL, +} from "./memberships.js"; + +// Role tools +export { + ROLE_LIST_TOOL, + ROLE_SHOW_TOOL, +} from "./roles.js"; + +// Issue category tools +export { + ISSUE_CATEGORY_LIST_TOOL, + ISSUE_CATEGORY_SHOW_TOOL, + ISSUE_CATEGORY_CREATE_TOOL, + ISSUE_CATEGORY_UPDATE_TOOL, + ISSUE_CATEGORY_DELETE_TOOL, +} from "./issue_categories.js"; + +// Attachment tools +export { + ATTACHMENT_UPLOAD_TOOL, + ATTACHMENT_GET_TOOL, + ATTACHMENT_UPDATE_TOOL, + ATTACHMENT_DELETE_TOOL, + ATTACHMENT_DOWNLOAD_TOOL, + ATTACHMENT_UPLOAD_FROM_PATH_TOOL, +} from "./attachments.js"; diff --git a/src/tools/issue_categories.ts b/src/tools/issue_categories.ts new file mode 100644 index 0000000..57b625f --- /dev/null +++ b/src/tools/issue_categories.ts @@ -0,0 +1,116 @@ +import { Tool } from "@modelcontextprotocol/sdk/types.js"; + +// List issue categories tool +export const ISSUE_CATEGORY_LIST_TOOL: Tool = { + name: "list_issue_categories", + description: + "Get list of issue categories for a project. " + + "Returns category ID, name, and default assignee. " + + "Available since Redmine 1.3", + inputSchema: { + type: "object", + properties: { + project_id: { + type: "string", + description: "Project ID as number or project identifier as text", + }, + }, + required: ["project_id"], + }, +}; + +// Show issue category tool +export const ISSUE_CATEGORY_SHOW_TOOL: Tool = { + name: "show_issue_category", + description: + "Get details of a specific issue category. " + + "Returns complete category information. " + + "Available since Redmine 1.3", + inputSchema: { + type: "object", + properties: { + id: { + type: "number", + description: "Issue category ID", + }, + }, + required: ["id"], + }, +}; + +// Create issue category tool +export const ISSUE_CATEGORY_CREATE_TOOL: Tool = { + name: "create_issue_category", + description: + "Create a new issue category for a project. " + + "Requires project ID and category name. " + + "Available since Redmine 1.3", + inputSchema: { + type: "object", + properties: { + project_id: { + type: "string", + description: "Project ID as number or project identifier as text", + }, + name: { + type: "string", + description: "Category name", + }, + assigned_to_id: { + type: "number", + description: "Default assignee user ID for issues in this category", + }, + }, + required: ["project_id", "name"], + }, +}; + +// Update issue category tool +export const ISSUE_CATEGORY_UPDATE_TOOL: Tool = { + name: "update_issue_category", + description: + "Update an existing issue category. " + + "Only specified fields will be changed. " + + "Available since Redmine 1.3", + inputSchema: { + type: "object", + properties: { + id: { + type: "number", + description: "Issue category ID to update", + }, + name: { + type: "string", + description: "New category name", + }, + assigned_to_id: { + type: "number", + description: "New default assignee user ID", + }, + }, + required: ["id"], + }, +}; + +// Delete issue category tool +export const ISSUE_CATEGORY_DELETE_TOOL: Tool = { + name: "delete_issue_category", + description: + "Delete an issue category. " + + "Optionally reassign issues to another category. " + + "Available since Redmine 1.3", + inputSchema: { + type: "object", + properties: { + id: { + type: "number", + description: "Issue category ID to delete", + }, + reassign_to_id: { + type: "number", + description: "Category ID to reassign issues to before deletion", + }, + }, + required: ["id"], + }, +}; diff --git a/src/tools/issue_statuses.ts b/src/tools/issue_statuses.ts new file mode 100644 index 0000000..8307916 --- /dev/null +++ b/src/tools/issue_statuses.ts @@ -0,0 +1,14 @@ +import { Tool } from "@modelcontextprotocol/sdk/types.js"; + +// List issue statuses tool +export const ISSUE_STATUS_LIST_TOOL: Tool = { + name: "list_issue_statuses", + description: + "Get list of all issue statuses. " + + "Returns status ID, name, and whether it represents a closed state. " + + "Available since Redmine 1.3", + inputSchema: { + type: "object", + properties: {}, + }, +}; diff --git a/src/tools/issues.ts b/src/tools/issues.ts index 566652f..7d44fb9 100644 --- a/src/tools/issues.ts +++ b/src/tools/issues.ts @@ -36,10 +36,8 @@ export const ISSUE_LIST_TOOL: Tool = { include: { type: "string", description: - "Additional data to include as comma separated values\n" + - "- attachments: file attachments\n" + - "- relations: issue relations", - pattern: "^(attachments|relations)(,(attachments|relations))*$", + "Additional data to include as comma-separated values: " + + "attachments, relations", }, // Basic filters issue_id: { @@ -61,8 +59,7 @@ export const ISSUE_LIST_TOOL: Tool = { }, status_id: { type: "string", - description: "Filter by open, closed, * for any, or specific status ID", - enum: ["open", "closed", "*"], + description: "Filter by 'open', 'closed', '*' for any, or specific numeric status ID", }, assigned_to_id: { type: "string", @@ -379,7 +376,8 @@ export const ISSUE_GET_TOOL: Tool = { include: { type: "string", description: - "Comma-separated list of associations to include (e.g., children, attachments, relations, journals, watchers)", + "Comma-separated list of associations to include: " + + "children, attachments, relations, changesets, journals, watchers, allowed_statuses", }, }, required: ["id"], diff --git a/src/tools/memberships.ts b/src/tools/memberships.ts new file mode 100644 index 0000000..42b1af1 --- /dev/null +++ b/src/tools/memberships.ts @@ -0,0 +1,129 @@ +import { Tool } from "@modelcontextprotocol/sdk/types.js"; + +// List memberships tool +export const MEMBERSHIP_LIST_TOOL: Tool = { + name: "list_memberships", + description: + "Get list of project memberships. " + + "Returns member ID, user/group info, and roles. " + + "Available since Redmine 1.4", + inputSchema: { + type: "object", + properties: { + project_id: { + type: "string", + description: "Project ID as number or project identifier as text", + }, + offset: { + type: "number", + description: "Number of memberships to skip", + minimum: 0, + default: 0, + }, + limit: { + type: "number", + description: "Maximum memberships to return, from 1 to 100", + minimum: 1, + maximum: 100, + default: 25, + }, + }, + required: ["project_id"], + }, +}; + +// Show membership tool +export const MEMBERSHIP_SHOW_TOOL: Tool = { + name: "show_membership", + description: + "Get details of a specific membership. " + + "Returns complete membership information. " + + "Available since Redmine 1.4", + inputSchema: { + type: "object", + properties: { + id: { + type: "number", + description: "Membership ID", + }, + }, + required: ["id"], + }, +}; + +// Create membership tool +export const MEMBERSHIP_CREATE_TOOL: Tool = { + name: "create_membership", + description: + "Add a user or group to a project. " + + "Requires project ID, user ID, and at least one role. " + + "Available since Redmine 1.4", + inputSchema: { + type: "object", + properties: { + project_id: { + type: "string", + description: "Project ID as number or project identifier as text", + }, + user_id: { + type: "number", + description: "User ID or group ID to add as member", + }, + role_ids: { + type: "array", + description: "List of role IDs to assign", + items: { + type: "number", + }, + minItems: 1, + }, + }, + required: ["project_id", "user_id", "role_ids"], + }, +}; + +// Update membership tool +export const MEMBERSHIP_UPDATE_TOOL: Tool = { + name: "update_membership", + description: + "Update membership roles. " + + "Cannot change the user or project. " + + "Available since Redmine 1.4", + inputSchema: { + type: "object", + properties: { + id: { + type: "number", + description: "Membership ID to update", + }, + role_ids: { + type: "array", + description: "New list of role IDs", + items: { + type: "number", + }, + minItems: 1, + }, + }, + required: ["id", "role_ids"], + }, +}; + +// Delete membership tool +export const MEMBERSHIP_DELETE_TOOL: Tool = { + name: "delete_membership", + description: + "Remove a user or group from a project. " + + "Cannot delete group-inherited memberships. " + + "Available since Redmine 1.4", + inputSchema: { + type: "object", + properties: { + id: { + type: "number", + description: "Membership ID to delete", + }, + }, + required: ["id"], + }, +}; diff --git a/src/tools/projects.ts b/src/tools/projects.ts index 753ce4b..d5c4b0f 100644 --- a/src/tools/projects.ts +++ b/src/tools/projects.ts @@ -14,12 +14,8 @@ export const PROJECT_LIST_TOOL: Tool = { include: { type: "string", description: - "Additional data to include as comma separated values\n" + - "- trackers: list project trackers\n" + - "- categories: list project categories\n" + - "- modules: list project modules. Since 2.6.0\n" + - "- time tracking: list time activities. Since 3.4.0", - pattern: "^(trackers|issue_categories|enabled_modules|time_entry_activities|issue_custom_fields)(,(trackers|issue_categories|enabled_modules|time_entry_activities|issue_custom_fields))*$" + "Comma-separated values: trackers, issue_categories, enabled_modules (2.6.0+), " + + "time_entry_activities (3.4.0+), issue_custom_fields (4.2.0+)", }, status: { type: "string", @@ -65,12 +61,8 @@ export const PROJECT_SHOW_TOOL: Tool = { include: { type: "string", description: - "Additional data to include as comma separated values\n" + - "- trackers: list project trackers\n" + - "- categories: list project categories\n" + - "- modules: list project modules. Since 2.6.0\n" + - "- time tracking: list time activities. Since 3.4.0", - pattern: "^(trackers|issue_categories|enabled_modules|time_entry_activities|issue_custom_fields)(,(trackers|issue_categories|enabled_modules|time_entry_activities|issue_custom_fields))*$" + "Comma-separated values: trackers, issue_categories, enabled_modules (2.6.0+), " + + "time_entry_activities (3.4.0+), issue_custom_fields (4.2.0+)", } }, required: ["id"] @@ -128,22 +120,29 @@ export const PROJECT_CREATE_TOOL: Tool = { }, enabled_module_names: { type: "array", - description: "List of enabled modules", + description: "List of enabled modules: boards, calendar, documents, files, gantt, issue_tracking, news, repository, time_tracking, wiki", items: { type: "string", - enum: [ - "boards", - "calendar", - "documents", - "files", - "gantt", - "issue_tracking", - "news", - "repository", - "time_tracking", - "wiki" - ] } + }, + default_assigned_to_id: { + type: "number", + description: "Default assignee ID for new issues" + }, + default_version_id: { + type: "number", + description: "Default version ID for new issues" + }, + issue_custom_field_ids: { + type: "array", + description: "List of issue custom field IDs to enable", + items: { + type: "number" + } + }, + custom_field_values: { + type: "object", + description: "Custom field values as key-value pairs (field_id => value)" } }, required: ["name", "identifier"] @@ -203,22 +202,29 @@ export const PROJECT_UPDATE_TOOL: Tool = { }, enabled_module_names: { type: "array", - description: "New list of enabled modules", + description: "New list of enabled modules: boards, calendar, documents, files, gantt, issue_tracking, news, repository, time_tracking, wiki", items: { type: "string", - enum: [ - "boards", - "calendar", - "documents", - "files", - "gantt", - "issue_tracking", - "news", - "repository", - "time_tracking", - "wiki" - ] } + }, + default_assigned_to_id: { + type: "number", + description: "Default assignee ID for new issues" + }, + default_version_id: { + type: "number", + description: "Default version ID for new issues" + }, + issue_custom_field_ids: { + type: "array", + description: "List of issue custom field IDs to enable", + items: { + type: "number" + } + }, + custom_field_values: { + type: "object", + description: "Custom field values as key-value pairs (field_id => value)" } }, required: ["id"] @@ -283,15 +289,23 @@ export const PROJECT_DELETE_TOOL: Tool = { } }; -// Add the new tool definition here +// List allowed issue statuses for project/tracker combination export const PROJECT_LIST_STATUSES_TOOL: Tool = { name: "list_project_statuses", - description: "指定されたRedmineプロジェクトの特定のトラッカーで利用可能なIssueステータスの一覧を取得", + description: + "Get allowed issue statuses for a project and tracker combination. " + + "Returns the list of statuses that can be used for issues with the specified tracker in the project.", inputSchema: { type: "object", properties: { - project_id: { type: "number" }, - tracker_id: { type: "number" } + project_id: { + type: "number", + description: "Project ID" + }, + tracker_id: { + type: "number", + description: "Tracker ID" + } }, required: ["project_id", "tracker_id"] } diff --git a/src/tools/roles.ts b/src/tools/roles.ts new file mode 100644 index 0000000..66d0f19 --- /dev/null +++ b/src/tools/roles.ts @@ -0,0 +1,33 @@ +import { Tool } from "@modelcontextprotocol/sdk/types.js"; + +// List roles tool +export const ROLE_LIST_TOOL: Tool = { + name: "list_roles", + description: + "Get list of all roles. " + + "Returns role ID and name. " + + "Available since Redmine 1.4", + inputSchema: { + type: "object", + properties: {}, + }, +}; + +// Show role tool +export const ROLE_SHOW_TOOL: Tool = { + name: "show_role", + description: + "Get details of a specific role including permissions. " + + "Returns role information with list of granted permissions. " + + "Available since Redmine 2.2", + inputSchema: { + type: "object", + properties: { + id: { + type: "number", + description: "Role ID", + }, + }, + required: ["id"], + }, +}; diff --git a/src/tools/trackers.ts b/src/tools/trackers.ts new file mode 100644 index 0000000..c4fa456 --- /dev/null +++ b/src/tools/trackers.ts @@ -0,0 +1,15 @@ +import { Tool } from "@modelcontextprotocol/sdk/types.js"; + +// List trackers tool +export const TRACKER_LIST_TOOL: Tool = { + name: "list_trackers", + description: + "Get list of all trackers. " + + "Returns tracker ID, name, default status, and enabled fields. " + + "Description available since 4.2.0, enabled_standard_fields since 5.0.0. " + + "Available since Redmine 1.3", + inputSchema: { + type: "object", + properties: {}, + }, +}; diff --git a/src/tools/users.ts b/src/tools/users.ts index 6b45b05..d64e8fe 100644 --- a/src/tools/users.ts +++ b/src/tools/users.ts @@ -65,10 +65,7 @@ export const USER_SHOW_TOOL: Tool = { include: { type: "string", description: - "Additional data to include as comma separated values\n" + - "- memberships: list project memberships and roles\n" + - "- groups: list group memberships. Since 2.1", - pattern: "^(memberships|groups)(,(memberships|groups))*$", + "Comma-separated values: memberships (project memberships and roles), groups (2.1+)", }, }, required: ["id"], diff --git a/src/tools/versions.ts b/src/tools/versions.ts new file mode 100644 index 0000000..2f9143e --- /dev/null +++ b/src/tools/versions.ts @@ -0,0 +1,150 @@ +import { Tool } from "@modelcontextprotocol/sdk/types.js"; + +// List versions tool +export const VERSION_LIST_TOOL: Tool = { + name: "list_versions", + description: + "Get list of versions for a project. " + + "Returns version ID, name, status, due date, and sharing settings. " + + "Available since Redmine 1.3", + inputSchema: { + type: "object", + properties: { + project_id: { + type: "string", + description: "Project ID as number or project identifier as text", + }, + }, + required: ["project_id"], + }, +}; + +// Show version tool +export const VERSION_SHOW_TOOL: Tool = { + name: "show_version", + description: + "Get details of a specific version. " + + "Returns complete version information including estimated and spent hours. " + + "Available since Redmine 1.3", + inputSchema: { + type: "object", + properties: { + id: { + type: "number", + description: "Version ID", + }, + }, + required: ["id"], + }, +}; + +// Create version tool +export const VERSION_CREATE_TOOL: Tool = { + name: "create_version", + description: + "Create a new version for a project. " + + "Requires project ID and version name. " + + "Available since Redmine 1.3", + inputSchema: { + type: "object", + properties: { + project_id: { + type: "string", + description: "Project ID as number or project identifier as text", + }, + name: { + type: "string", + description: "Version name", + }, + status: { + type: "string", + description: "Version status: open (default), locked, or closed", + enum: ["open", "locked", "closed"], + }, + sharing: { + type: "string", + description: "Version sharing: none (default), descendants, hierarchy, tree, or system", + enum: ["none", "descendants", "hierarchy", "tree", "system"], + }, + due_date: { + type: "string", + description: "Due date in YYYY-MM-DD format", + pattern: "^\\d{4}-\\d{2}-\\d{2}$", + }, + description: { + type: "string", + description: "Version description", + }, + wiki_page_title: { + type: "string", + description: "Associated wiki page title", + }, + }, + required: ["project_id", "name"], + }, +}; + +// Update version tool +export const VERSION_UPDATE_TOOL: Tool = { + name: "update_version", + description: + "Update an existing version. " + + "Only specified fields will be changed. " + + "Available since Redmine 1.3", + inputSchema: { + type: "object", + properties: { + id: { + type: "number", + description: "Version ID to update", + }, + name: { + type: "string", + description: "New version name", + }, + status: { + type: "string", + description: "Version status: open, locked, or closed", + enum: ["open", "locked", "closed"], + }, + sharing: { + type: "string", + description: "Version sharing: none, descendants, hierarchy, tree, or system", + enum: ["none", "descendants", "hierarchy", "tree", "system"], + }, + due_date: { + type: "string", + description: "Due date in YYYY-MM-DD format", + pattern: "^\\d{4}-\\d{2}-\\d{2}$", + }, + description: { + type: "string", + description: "Version description", + }, + wiki_page_title: { + type: "string", + description: "Associated wiki page title", + }, + }, + required: ["id"], + }, +}; + +// Delete version tool +export const VERSION_DELETE_TOOL: Tool = { + name: "delete_version", + description: + "Delete a version permanently. " + + "This action cannot be undone. " + + "Available since Redmine 1.3", + inputSchema: { + type: "object", + properties: { + id: { + type: "number", + description: "Version ID to delete", + }, + }, + required: ["id"], + }, +};