diff --git a/.gitignore b/.gitignore index 6dd0b75..46477d5 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,7 @@ jspm_packages/ # Test files test-*.js -test-*.cjs \ No newline at end of file +test-*.cjs + +# Coding Agent files +.cursor/* \ No newline at end of file diff --git a/package.json b/package.json index 02ac25a..35755ea 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,11 @@ "@modelcontextprotocol/sdk": "^0.4.0", "axios": "^1.6.0", "dotenv": "^16.3.1", + "form-data": "^4.0.0", "zod": "^3.22.4" }, "devDependencies": { + "@types/form-data": "^2.5.0", "@types/node": "^20.10.0", "@typescript-eslint/eslint-plugin": "^6.13.0", "@typescript-eslint/parser": "^6.13.0", diff --git a/src/client/openproject-client.ts b/src/client/openproject-client.ts index 87434af..54f2aa4 100644 --- a/src/client/openproject-client.ts +++ b/src/client/openproject-client.ts @@ -8,6 +8,7 @@ import { TimeEntry, Grid, Board, + StatusCollectionResponse, CollectionResponse, QueryParams, OpenProjectError, @@ -17,7 +18,20 @@ import { TimeEntrySchema, GridSchema, BoardSchema, + StatusCollectionResponseSchema, CollectionResponseSchema, + MembershipCollectionResponse, + MembershipCollectionResponseSchema, + MembershipSchema, + RoleCollectionResponse, + RoleCollectionResponseSchema, + RoleSchema, + AttachmentSchema, + AttachmentCollectionResponseSchema, + Membership, + Role, + Attachment, + AttachmentCollectionResponse, } from '../types/openproject.js'; import { logger } from '../utils/logger.js'; @@ -176,6 +190,18 @@ export class OpenProjectClient { logger.logApiRequest('GET', url, undefined, params); const response = await this.axiosInstance.get(url); + // Extract readable description text and transform _links for all work packages + if (response.data._embedded?.elements) { + response.data._embedded.elements.forEach((wp: any) => { + if (wp.description && typeof wp.description === 'object') { + wp.description = this.extractDescriptionText(wp.description); + } + + // Transform _links structure to match schema expectations + Object.assign(wp, this.transformWorkPackageLinks(wp)); + }); + } + logger.logApiResponse('GET', url, response.status, response.headers, response.data); logger.logRawData('getWorkPackages response', response.data); @@ -201,12 +227,60 @@ export class OpenProjectClient { async getWorkPackage(id: number): Promise { const response = await this.axiosInstance.get(`/work_packages/${id}`); logger.debug('Raw work package API response', { id, responseData: response.data }); - return WorkPackageSchema.parse(response.data); + + // Extract readable description text if it's an object + if (response.data.description && typeof response.data.description === 'object') { + response.data.description = this.extractDescriptionText(response.data.description); + } + + // Transform _links structure to match schema expectations + const transformedData = this.transformWorkPackageLinks(response.data); + + logger.debug('Transformed work package data', { transformedData }); + + return WorkPackageSchema.parse(transformedData); } async createWorkPackage(workPackageData: Partial): Promise { - const response = await this.axiosInstance.post('/work_packages', workPackageData); - return WorkPackageSchema.parse(response.data); + const startTime = Date.now(); + const url = '/work_packages'; + + try { + // Transform description field to proper OpenProject format BEFORE sending to API + const transformedData = { ...workPackageData }; + if (transformedData.description && typeof transformedData.description === 'string') { + transformedData.description = { + format: 'text', + raw: transformedData.description + }; + logger.debug('Transformed description to OpenProject format for creation', { + originalDescription: workPackageData.description, + transformedDescription: transformedData.description + }); + } + + logger.logApiRequest('POST', url, transformedData); + const response = await this.axiosInstance.post(url, transformedData); + + logger.logApiResponse('POST', url, response.status, response.headers, response.data); + + const duration = Date.now() - startTime; + logger.info(`createWorkPackage completed successfully (${duration}ms)`, { + newWorkPackageId: response.data.id, + subject: response.data.subject + }); + + return WorkPackageSchema.parse(response.data); + } catch (error: any) { + const duration = Date.now() - startTime; + logger.error(`createWorkPackage failed (${duration}ms)`, { + error: error.message, + status: error.response?.status, + responseData: error.response?.data, + workPackageData + }); + throw error; + } } async updateWorkPackage(id: number, workPackageData: Partial): Promise { @@ -228,10 +302,24 @@ export class OpenProjectClient { currentAssignee: currentWorkPackage._links?.assignee?.title }); - // Build the update payload exactly like the working script + // Transform description field to proper OpenProject format BEFORE building payload + const transformedData = { ...workPackageData }; + if (transformedData.description && typeof transformedData.description === 'string') { + transformedData.description = { + format: 'text', + raw: transformedData.description + }; + logger.debug('Transformed description to OpenProject format', { + id, + originalDescription: workPackageData.description, + transformedDescription: transformedData.description + }); + } + + // Build the update payload with transformed data const updatePayload: any = { lockVersion: lockVersion, - ...workPackageData // Spread all the update data directly + ...transformedData // Spread the transformed data }; // Remove nested objects that need special handling @@ -284,7 +372,12 @@ export class OpenProjectClient { newLockVersion: response.data.lockVersion }); - return WorkPackageSchema.parse(response.data); + // Transform the response data to match schema expectations + const transformedResponseData = this.transformWorkPackageLinks(response.data); + + logger.debug('Transformed update response data', { transformedResponseData }); + + return WorkPackageSchema.parse(transformedResponseData); } catch (error: any) { const duration = Date.now() - startTime; logger.error(`updateWorkPackage failed (${duration}ms)`, { @@ -474,6 +567,13 @@ export class OpenProjectClient { await this.axiosInstance.delete(`/users/${id}`); } + // Statuses API + async getStatuses(params: QueryParams = {}): Promise { + const queryString = this.buildQueryString(params); + const response = await this.axiosInstance.get(`/statuses${queryString}`); + return StatusCollectionResponseSchema.parse(response.data); + } + // Time Entries API async getTimeEntries(params: QueryParams = {}): Promise { const queryString = this.buildQueryString(params); @@ -500,6 +600,230 @@ export class OpenProjectClient { await this.axiosInstance.delete(`/time_entries/${id}`); } + // Membership API methods + async getMemberships(params: QueryParams = {}): Promise { + const startTime = Date.now(); + const queryString = this.buildQueryString(params); + const url = `/memberships${queryString}`; + + try { + logger.logApiRequest('GET', url, undefined, params); + const response = await this.axiosInstance.get(url); + + logger.logApiResponse('GET', url, response.status, response.headers, response.data); + logger.logRawData('getMemberships response', response.data); + + const duration = Date.now() - startTime; + logger.info(`getMemberships completed successfully (${duration}ms)`, { + totalMemberships: response.data?.total, + returnedCount: response.data?._embedded?.elements?.length + }); + + return MembershipCollectionResponseSchema.parse(response.data); + } catch (error: any) { + const duration = Date.now() - startTime; + logger.error(`getMemberships failed (${duration}ms)`, { + error: error.message, + status: error.response?.status, + responseData: error.response?.data, + params + }); + throw error; + } + } + + async getMembership(id: number): Promise { + const startTime = Date.now(); + const url = `/memberships/${id}`; + + try { + logger.logApiRequest('GET', url, undefined, { id }); + const response = await this.axiosInstance.get(url); + + logger.logApiResponse('GET', url, response.status, response.headers, response.data); + logger.logRawData('getMembership response', response.data); + + const duration = Date.now() - startTime; + logger.info(`getMembership completed successfully (${duration}ms)`, { + membershipId: id, + projectName: response.data?.project?.name, + userName: response.data?.principal?.name + }); + + return MembershipSchema.parse(response.data); + } catch (error: any) { + const duration = Date.now() - startTime; + logger.error(`getMembership failed (${duration}ms)`, { + error: error.message, + status: error.response?.status, + responseData: error.response?.data, + membershipId: id + }); + throw error; + } + } + + async createMembership(membershipData: { projectId: number; userId: number; roleIds: number[] }): Promise { + const startTime = Date.now(); + const url = '/memberships'; + + try { + const payload = { + _links: { + project: { href: `/api/v3/projects/${membershipData.projectId}` }, + principal: { href: `/api/v3/users/${membershipData.userId}` }, + roles: membershipData.roleIds.map(roleId => ({ href: `/api/v3/roles/${roleId}` })) + } + }; + + logger.logApiRequest('POST', url, payload, membershipData); + const response = await this.axiosInstance.post(url, payload); + + logger.logApiResponse('POST', url, response.status, response.headers, response.data); + + const duration = Date.now() - startTime; + logger.info(`createMembership completed successfully (${duration}ms)`, { + newMembershipId: response.data.id, + projectId: membershipData.projectId, + userId: membershipData.userId, + roleCount: membershipData.roleIds.length + }); + + return MembershipSchema.parse(response.data); + } catch (error: any) { + const duration = Date.now() - startTime; + logger.error(`createMembership failed (${duration}ms)`, { + error: error.message, + status: error.response?.status, + responseData: error.response?.data, + membershipData + }); + throw error; + } + } + + async updateMembership(id: number, roleIds: number[]): Promise { + const startTime = Date.now(); + const url = `/memberships/${id}`; + + try { + const payload = { + _links: { + roles: roleIds.map(roleId => ({ href: `/api/v3/roles/${roleId}` })) + } + }; + + logger.logApiRequest('PATCH', url, payload, { id, roleIds }); + const response = await this.axiosInstance.patch(url, payload); + + logger.logApiResponse('PATCH', url, response.status, response.headers, response.data); + + const duration = Date.now() - startTime; + logger.info(`updateMembership completed successfully (${duration}ms)`, { + membershipId: id, + newRoleCount: roleIds.length + }); + + return MembershipSchema.parse(response.data); + } catch (error: any) { + const duration = Date.now() - startTime; + logger.error(`updateMembership failed (${duration}ms)`, { + error: error.message, + status: error.response?.status, + responseData: error.response?.data, + membershipId: id, + roleIds + }); + throw error; + } + } + + async deleteMembership(id: number): Promise { + const startTime = Date.now(); + const url = `/memberships/${id}`; + + try { + logger.logApiRequest('DELETE', url, undefined, { id }); + await this.axiosInstance.delete(url); + + const duration = Date.now() - startTime; + logger.info(`deleteMembership completed successfully (${duration}ms)`, { + membershipId: id + }); + } catch (error: any) { + const duration = Date.now() - startTime; + logger.error(`deleteMembership failed (${duration}ms)`, { + error: error.message, + status: error.response?.status, + responseData: error.response?.data, + membershipId: id + }); + throw error; + } + } + + // Role API methods + async getRoles(params: QueryParams = {}): Promise { + const startTime = Date.now(); + const queryString = this.buildQueryString(params); + const url = `/roles${queryString}`; + + try { + logger.logApiRequest('GET', url, undefined, params); + const response = await this.axiosInstance.get(url); + + logger.logApiResponse('GET', url, response.status, response.headers, response.data); + logger.logRawData('getRoles response', response.data); + + const duration = Date.now() - startTime; + logger.info(`getRoles completed successfully (${duration}ms)`, { + totalRoles: response.data?.total, + returnedCount: response.data?._embedded?.elements?.length + }); + + return RoleCollectionResponseSchema.parse(response.data); + } catch (error: any) { + const duration = Date.now() - startTime; + logger.error(`getRoles failed (${duration}ms)`, { + error: error.message, + status: error.response?.status, + responseData: error.response?.data, + params + }); + throw error; + } + } + + async getRole(id: number): Promise { + const startTime = Date.now(); + const url = `/roles/${id}`; + + try { + logger.logApiRequest('GET', url, undefined, { id }); + const response = await this.axiosInstance.get(url); + + logger.logApiResponse('GET', url, response.status, response.headers, response.data); + logger.logRawData('getRole response', response.data); + + const duration = Date.now() - startTime; + logger.info(`getRole completed successfully (${duration}ms)`, { + roleId: id, + roleName: response.data?.name + }); + + return RoleSchema.parse(response.data); + } catch (error: any) { + const duration = Date.now() - startTime; + logger.error(`getRole failed (${duration}ms)`, { + error: error.message, + status: error.response?.status, + responseData: error.response?.data, + roleId: id + }); + throw error; + } + } + // Utility methods async testConnection(): Promise { try { @@ -649,4 +973,340 @@ export class OpenProjectClient { }, }); } + + private extractDescriptionText(description: any): string { + if (typeof description === 'string') { + return description; + } + if (typeof description === 'object' && description !== null) { + if (typeof description.raw === 'string') { + return description.raw; + } + if (typeof description.html === 'string') { + // Strip HTML tags to return plain text + return description.html.replace(/<[^>]*>/g, ''); + } + if (typeof description.plain === 'string') { + return description.plain; + } + } + return ''; + } + + private transformWorkPackageLinks(data: any): any { + const transformedData = { ...data }; + + // Transform status from _links.status to status object + if (transformedData._links?.status) { + const statusHref = transformedData._links.status.href; + const statusId = statusHref ? parseInt(statusHref.split('/').pop() || '0') : null; + if (statusId) { + transformedData.status = { + id: statusId, + name: transformedData._links.status.title || 'Unknown' + }; + } + } + + // Transform project from _links.project to project object + if (transformedData._links?.project) { + const projectHref = transformedData._links.project.href; + const projectId = projectHref ? parseInt(projectHref.split('/').pop() || '0') : null; + if (projectId) { + transformedData.project = { + id: projectId, + name: transformedData._links.project.title || 'Unknown' + }; + } + } + + // Transform type from _links.type to type object + if (transformedData._links?.type) { + const typeHref = transformedData._links.type.href; + const typeId = typeHref ? parseInt(typeHref.split('/').pop() || '0') : null; + if (typeId) { + transformedData.type = { + id: typeId, + name: transformedData._links.type.title || 'Unknown' + }; + } + } + + // Transform priority from _links.priority to priority object + if (transformedData._links?.priority) { + const priorityHref = transformedData._links.priority.href; + const priorityId = priorityHref ? parseInt(priorityHref.split('/').pop() || '0') : null; + if (priorityId) { + transformedData.priority = { + id: priorityId, + name: transformedData._links.priority.title || 'Unknown' + }; + } + } + + // Transform assignee from _links.assignee to assignee object + if (transformedData._links?.assignee) { + const assigneeHref = transformedData._links.assignee.href; + const assigneeId = assigneeHref ? parseInt(assigneeHref.split('/').pop() || '0') : null; + if (assigneeId) { + transformedData.assignee = { + id: assigneeId, + name: transformedData._links.assignee.title || 'Unknown' + }; + } + } + + // Transform responsible from _links.responsible to responsible object + if (transformedData._links?.responsible) { + const responsibleHref = transformedData._links.responsible.href; + const responsibleId = responsibleHref ? parseInt(responsibleHref.split('/').pop() || '0') : null; + if (responsibleId) { + transformedData.responsible = { + id: responsibleId, + name: transformedData._links.responsible.title || 'Unknown' + }; + } + } + + return transformedData; + } + + // Attachment API methods + async uploadAttachment(workPackageId: number, filePath: string, description?: string): Promise { + const startTime = Date.now(); + const url = `/work_packages/${workPackageId}/attachments`; + + try { + // Import fs and path modules for file operations + const fs = await import('fs'); + const path = await import('path'); + + // Check if file exists + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + // Get file stats and determine content type + const stats = fs.statSync(filePath); + const fileName = path.basename(filePath); + const fileExtension = path.extname(filePath).toLowerCase(); + + // Determine content type based on file extension + const contentTypeMap: Record = { + '.txt': 'text/plain', + '.md': 'text/markdown', + '.json': 'application/json', + '.xml': 'application/xml', + '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.zip': 'application/zip', + '.tar': 'application/x-tar', + '.gz': 'application/gzip', + '.csv': 'text/csv', + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.ts': 'application/typescript', + '.py': 'text/x-python', + '.java': 'text/x-java-source', + '.cpp': 'text/x-c++src', + '.c': 'text/x-csrc', + '.h': 'text/x-chdr', + '.sql': 'application/sql', + '.yaml': 'application/x-yaml', + '.yml': 'application/x-yaml', + }; + + const contentType = contentTypeMap[fileExtension] || 'application/octet-stream'; + + // Read file content + const fileBuffer = fs.readFileSync(filePath); + + // Manually construct multipart form data to avoid FormData issues + const boundary = `----WebKitFormBoundary${Math.random().toString(36).substring(2)}`; + + // Create metadata part + const metadata = { + fileName: fileName, + ...(description && { description: description }) + }; + const metadataPart = `--${boundary}\r\nContent-Disposition: form-data; name="metadata"\r\nContent-Type: application/json\r\n\r\n${JSON.stringify(metadata)}\r\n`; + + // Create file part + const filePart = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`; + + // Combine all parts + const endBoundary = `--${boundary}--\r\n`; + const body = Buffer.concat([ + Buffer.from(metadataPart), + Buffer.from(filePart), + fileBuffer, + Buffer.from('\r\n' + endBoundary) + ]); + + logger.logApiRequest('POST', url, { filePath, fileName, contentType, fileSize: stats.size, description }, { workPackageId }); + logger.debug('Manual multipart boundary', boundary); + logger.debug('File buffer length', fileBuffer.length); + + const response = await this.axiosInstance.post(url, body, { + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': body.length.toString() + } + }); + + logger.logApiResponse('POST', url, response.status, response.headers, response.data); + logger.logRawData('uploadAttachment response', response.data); + + const duration = Date.now() - startTime; + logger.info(`uploadAttachment completed successfully (${duration}ms)`, { + workPackageId, + filePath, + fileName, + fileSize: stats.size, + attachmentId: response.data?.id, + uploadedFileSize: response.data?.fileSize + }); + + return AttachmentSchema.parse(response.data); + } catch (error: any) { + const duration = Date.now() - startTime; + logger.error(`uploadAttachment failed (${duration}ms)`, { + error: error.message, + status: error.response?.status, + responseData: error.response?.data, + workPackageId, + filePath + }); + throw error; + } + } + + async getWorkPackageAttachments(workPackageId: number): Promise { + const startTime = Date.now(); + const url = `/work_packages/${workPackageId}/attachments`; + + try { + logger.logApiRequest('GET', url, undefined, { workPackageId }); + const response = await this.axiosInstance.get(url); + + logger.logApiResponse('GET', url, response.status, response.headers, response.data); + logger.logRawData('getWorkPackageAttachments response', response.data); + + const duration = Date.now() - startTime; + logger.info(`getWorkPackageAttachments completed successfully (${duration}ms)`, { + workPackageId, + totalAttachments: response.data?.total, + returnedCount: response.data?._embedded?.elements?.length + }); + + return AttachmentCollectionResponseSchema.parse(response.data); + } catch (error: any) { + const duration = Date.now() - startTime; + logger.error(`getWorkPackageAttachments failed (${duration}ms)`, { + error: error.message, + status: error.response?.status, + responseData: error.response?.data, + workPackageId + }); + throw error; + } + } + + async downloadAttachment(attachmentId: number, outputPath?: string): Promise<{ filePath: string; fileName: string; fileSize: number }> { + const startTime = Date.now(); + const metaUrl = `/attachments/${attachmentId}`; + + try { + logger.logApiRequest('GET', metaUrl, undefined, { attachmentId }); + const metaResponse = await this.axiosInstance.get(metaUrl); + + logger.logApiResponse('GET', metaUrl, metaResponse.status, metaResponse.headers, metaResponse.data); + logger.logRawData('downloadAttachment metadata response', metaResponse.data); + + const attachment = AttachmentSchema.parse(metaResponse.data); + + // Prefer downloadLocation, fall back to staticDownloadLocation + const href = (attachment._links as any).downloadLocation?.href || (attachment._links as any).staticDownloadLocation?.href; + if (!href) { + throw new Error('Download URL not available for this attachment'); + } + + // Build absolute download URL to avoid double /api/v3 when href is relative + let downloadUrl = href; + const instanceBase = this.axiosInstance.defaults.baseURL || ''; + const apiRoot = instanceBase.replace(/\/api\/v3\/?$/, ''); + const isAbsolute = /^https?:\/\//i.test(href); + if (!isAbsolute) { + // href likely begins with /api/v3/... or another relative path; prefix with API root + downloadUrl = `${apiRoot}${href.startsWith('/') ? '' : '/'}${href}`; + } + + logger.logApiRequest('GET', downloadUrl, undefined, { attachmentId, fileName: attachment.fileName }); + // Use absolute URL to bypass axios baseURL + const downloadResponse = await this.axiosInstance.get(downloadUrl, { responseType: 'arraybuffer' }); + logger.logApiResponse('GET', downloadUrl, downloadResponse.status, downloadResponse.headers, `<>`); + + // Dynamically import fs and path for ESM compatibility + const fs = await import('fs'); + const path = await import('path'); + + // Resolve final file path + let finalFilePath: string; + if (outputPath) { + try { + const stat = fs.statSync(outputPath); + if (stat.isDirectory()) { + finalFilePath = path.join(outputPath, attachment.fileName); + } else { + finalFilePath = outputPath; // Treat as file path + } + } catch { + // Path does not exist; if it ends with path separator treat as dir, else treat as file path + const endsWithSep = outputPath.endsWith(path.sep) || outputPath.endsWith('/') || outputPath.endsWith('\\'); + finalFilePath = endsWithSep ? path.join(outputPath, attachment.fileName) : outputPath; + } + } else { + const defaultDir = path.join(process.cwd(), 'downloads'); + finalFilePath = path.join(defaultDir, attachment.fileName); + } + + // Ensure directory exists + const dirName = path.dirname(finalFilePath); + fs.mkdirSync(dirName, { recursive: true }); + + // Write file + const data: Buffer = downloadResponse.data as Buffer; + fs.writeFileSync(finalFilePath, data); + + const duration = Date.now() - startTime; + logger.info(`downloadAttachment completed successfully (${duration}ms)`, { + attachmentId, + fileName: attachment.fileName, + fileSize: data.byteLength, + savedTo: finalFilePath, + }); + + return { filePath: finalFilePath, fileName: attachment.fileName, fileSize: data.byteLength }; + } catch (error: any) { + const duration = Date.now() - startTime; + logger.error(`downloadAttachment failed (${duration}ms)`, { + attachmentId, + error: error.message, + status: error.response?.status, + responseData: error.response?.data, + }); + throw error; + } + } } \ No newline at end of file diff --git a/src/handlers/tool-handlers.ts b/src/handlers/tool-handlers.ts index a12c30a..111d9d6 100644 --- a/src/handlers/tool-handlers.ts +++ b/src/handlers/tool-handlers.ts @@ -10,13 +10,16 @@ import { GetWorkPackagesArgsSchema, GetWorkPackageArgsSchema, CreateWorkPackageArgsSchema, + CreateTaskArgsSchema, UpdateWorkPackageArgsSchema, + SetWorkPackageStatusArgsSchema, DeleteWorkPackageArgsSchema, SetWorkPackageParentArgsSchema, RemoveWorkPackageParentArgsSchema, GetWorkPackageChildrenArgsSchema, SearchArgsSchema, GetUsersArgsSchema, + GetStatusesArgsSchema, GetTimeEntriesArgsSchema, CreateTimeEntryArgsSchema, GetBoardsArgsSchema, @@ -26,6 +29,16 @@ import { DeleteBoardArgsSchema, AddBoardWidgetArgsSchema, RemoveBoardWidgetArgsSchema, + GetMembershipsArgsSchema, + GetMembershipArgsSchema, + CreateMembershipArgsSchema, + UpdateMembershipArgsSchema, + DeleteMembershipArgsSchema, + GetRolesArgsSchema, + GetRoleArgsSchema, + UploadAttachmentArgsSchema, + GetAttachmentsArgsSchema, + DownloadAttachmentArgsSchema, } from '../tools/index.js'; export class OpenProjectToolHandlers { @@ -68,9 +81,15 @@ export class OpenProjectToolHandlers { case 'create_work_package': result = await this.handleCreateWorkPackage(args); break; + case 'create_task': + result = await this.handleCreateTask(args); + break; case 'update_work_package': result = await this.handleUpdateWorkPackage(args); break; + case 'set_work_package_status': + result = await this.handleSetWorkPackageStatus(args); + break; case 'delete_work_package': result = await this.handleDeleteWorkPackage(args); break; @@ -99,6 +118,11 @@ export class OpenProjectToolHandlers { result = await this.handleGetCurrentUser(); break; + // Status handlers + case 'get_statuses': + result = await this.handleGetStatuses(args); + break; + // Time Entry handlers case 'get_time_entries': result = await this.handleGetTimeEntries(args); @@ -130,6 +154,42 @@ export class OpenProjectToolHandlers { result = await this.handleRemoveBoardWidget(args); break; + // Membership handlers + case 'get_memberships': + result = await this.handleGetMemberships(args); + break; + case 'get_membership': + result = await this.handleGetMembership(args); + break; + case 'create_membership': + result = await this.handleCreateMembership(args); + break; + case 'update_membership': + result = await this.handleUpdateMembership(args); + break; + case 'delete_membership': + result = await this.handleDeleteMembership(args); + break; + + // Role handlers + case 'get_roles': + result = await this.handleGetRoles(args); + break; + case 'get_role': + result = await this.handleGetRole(args); + break; + + // Upload Attachment handlers + case 'upload_attachment': + result = await this.handleUploadAttachment(args); + break; + case 'get_attachments': + result = await this.handleGetAttachments(args); + break; + case 'download_attachment': + result = await this.handleDownloadAttachment(args); + break; + // Utility handlers case 'test_connection': result = await this.handleTestConnection(); @@ -404,7 +464,7 @@ export class OpenProjectToolHandlers { private async handleCreateWorkPackage(args: any) { const validatedArgs = CreateWorkPackageArgsSchema.parse(args); - + // Transform the arguments to match OpenProject API format const workPackageData = { subject: validatedArgs.subject, @@ -420,9 +480,9 @@ export class OpenProjectToolHandlers { ...(validatedArgs.assigneeId && { assignee: { href: `/api/v3/users/${validatedArgs.assigneeId}` } }), }, }; - + const workPackage = await this.client.createWorkPackage(workPackageData); - + return { content: [ { @@ -433,6 +493,38 @@ export class OpenProjectToolHandlers { }; } + private async handleCreateTask(args: any) { + const validatedArgs = CreateTaskArgsSchema.parse(args); + + // Transform the arguments to match OpenProject API format + // Automatically set typeId to 1 (Task type) as specified in the requirements + const workPackageData = { + subject: validatedArgs.subject, + ...(validatedArgs.description !== undefined && { description: validatedArgs.description }), + ...(validatedArgs.startDate !== undefined && { startDate: validatedArgs.startDate }), + ...(validatedArgs.dueDate !== undefined && { dueDate: validatedArgs.dueDate }), + ...(validatedArgs.estimatedTime !== undefined && { estimatedTime: validatedArgs.estimatedTime }), + _links: { + project: { href: `/api/v3/projects/${validatedArgs.projectId}` }, + type: { href: `/api/v3/types/1` }, // Always set to Task type (ID: 1) + ...(validatedArgs.statusId && { status: { href: `/api/v3/statuses/${validatedArgs.statusId}` } }), + ...(validatedArgs.priorityId && { priority: { href: `/api/v3/priorities/${validatedArgs.priorityId}` } }), + ...(validatedArgs.assigneeId && { assignee: { href: `/api/v3/users/${validatedArgs.assigneeId}` } }), + }, + }; + + const workPackage = await this.client.createWorkPackage(workPackageData); + + return { + content: [ + { + type: 'text', + text: `Task created successfully:\n\nSubject: ${workPackage.subject}\nID: ${workPackage.id}\nProject: ${workPackage.project?.name || 'Unknown'}\nType: ${workPackage.type?.name || 'Task'}\nStatus: ${workPackage.status?.name || 'Unknown'}`, + }, + ], + }; + } + private async handleUpdateWorkPackage(args: any) { const validatedArgs = UpdateWorkPackageArgsSchema.parse(args); const { id, ...updateData } = validatedArgs; @@ -471,6 +563,45 @@ export class OpenProjectToolHandlers { }; } + private async handleSetWorkPackageStatus(args: any) { + try { + logger.debug('Validating args for set_work_package_status', { args }); + const validatedArgs = SetWorkPackageStatusArgsSchema.parse(args); + logger.logSchemaValidation('set_work_package_status', args); + + // Update the work package status + const result = await this.client.updateWorkPackage(validatedArgs.id, { status: { id: validatedArgs.statusId, name: 'dummy' } }); + + logger.logRawData('setWorkPackageStatus client result', result); + + // Find the status name for the response by fetching from API + const statusesResponse = await this.client.getStatuses(); + const status = statusesResponse._embedded.elements.find(s => s.id === validatedArgs.statusId); + const statusName = status ? status.name : `ID ${validatedArgs.statusId}`; + + return { + content: [ + { + type: 'text', + text: `Work package ${validatedArgs.id} status updated to "${statusName}" successfully.`, + }, + ], + }; + } catch (error) { + logger.error('Error setting work package status', { error, args }); + + // Return a very directive error message that forces the agent to stop + const errorMessage = error instanceof Error ? error.message : String(error); + return { + content: [{ + type: 'text', + text: `ERROR: ${errorMessage}. STOP. Do not investigate. Ask user for valid status ID.` + }], + isError: true + }; + } + } + private async handleDeleteWorkPackage(args: any) { const validatedArgs = DeleteWorkPackageArgsSchema.parse(args); await this.client.deleteWorkPackage(validatedArgs.id); @@ -668,6 +799,28 @@ export class OpenProjectToolHandlers { }; } + // Status handlers + private async handleGetStatuses(args: any) { + const validatedArgs = GetStatusesArgsSchema.parse(args); + const queryParams = { + ...(validatedArgs.offset !== undefined && { offset: validatedArgs.offset }), + ...(validatedArgs.pageSize !== undefined && { pageSize: validatedArgs.pageSize }), + ...(validatedArgs.filters !== undefined && { filters: validatedArgs.filters }), + }; + const result = await this.client.getStatuses(queryParams); + + return { + content: [ + { + type: 'text', + text: `Found ${result.total} statuses:\n\n${result._embedded.elements + .map((status: any) => `- ${status.id}: ${status.name} (${status.isClosed ? 'Closed' : 'Open'}) - ${status.defaultDoneRatio || 0}% done - Color: ${status.color || 'Default'}`) + .join('\n')}`, + }, + ], + }; + } + // Time Entry handlers private async handleGetTimeEntries(args: any) { const validatedArgs = GetTimeEntriesArgsSchema.parse(args); @@ -737,35 +890,6 @@ export class OpenProjectToolHandlers { }; } - // Utility handlers - private async handleTestConnection() { - const isConnected = await this.client.testConnection(); - - return { - content: [ - { - type: 'text', - text: isConnected - ? 'Connection to OpenProject API successful!' - : 'Failed to connect to OpenProject API. Please check your configuration.', - }, - ], - }; - } - - private async handleGetApiInfo() { - const apiInfo = await this.client.getApiInfo(); - - return { - content: [ - { - type: 'text', - text: `OpenProject API Information:\n\n${JSON.stringify(apiInfo, null, 2)}`, - }, - ], - }; - } - // Board handlers private async handleGetBoards(args: any) { const parsedArgs = GetBoardsArgsSchema.parse(args); @@ -901,5 +1025,225 @@ export class OpenProjectToolHandlers { }, ], }; + } + + // Membership handlers + private async handleGetMemberships(args: any) { + const validatedArgs = GetMembershipsArgsSchema.parse(args); + const queryParams: any = {}; + if (validatedArgs.offset !== undefined) queryParams.offset = validatedArgs.offset; + if (validatedArgs.pageSize !== undefined) queryParams.pageSize = validatedArgs.pageSize; + if (validatedArgs.filters !== undefined) queryParams.filters = validatedArgs.filters; + if (validatedArgs.sortBy !== undefined) queryParams.sortBy = validatedArgs.sortBy; + + const memberships = await this.client.getMemberships(queryParams); + + return { + content: [ + { + type: 'text', + text: `Found ${memberships.total} memberships:\n\n${memberships._embedded.elements.map((membership: any) => + `ID: ${membership.id}\nUser: ${membership._links.principal.title || 'N/A'}\nProject: ${membership._links.project.title || 'N/A'}\nRoles: ${membership._links.roles.map((r: any) => r.title).join(', ') || 'N/A'}\nCreated: ${membership.createdAt || 'N/A'}\n` + ).join('\n')}`, + }, + ], + }; + } + + private async handleGetMembership(args: any) { + const validatedArgs = GetMembershipArgsSchema.parse(args); + const membership = await this.client.getMembership(validatedArgs.id); + + return { + content: [ + { + type: 'text', + text: `Membership Details:\n\nID: ${membership.id}\nUser: ${membership._links.principal.title || 'N/A'}\nProject: ${membership._links.project.title || 'N/A'}\nRoles: ${membership._links.roles.map((r: any) => r.title).join(', ') || 'N/A'}\nCreated: ${membership.createdAt || 'N/A'}\nUpdated: ${membership.updatedAt || 'N/A'}`, + }, + ], + }; + } + + private async handleCreateMembership(args: any) { + const validatedArgs = CreateMembershipArgsSchema.parse(args); + const membership = await this.client.createMembership(validatedArgs); + + return { + content: [ + { + type: 'text', + text: `Membership created successfully:\n\nID: ${membership.id}\nUser: ${membership._links.principal.title || 'N/A'}\nProject: ${membership._links.project.title || 'N/A'}\nRoles: ${membership._links.roles.map((r: any) => r.title).join(', ') || 'N/A'}\nCreated: ${membership.createdAt || 'N/A'}`, + }, + ], + }; + } + + private async handleUpdateMembership(args: any) { + const validatedArgs = UpdateMembershipArgsSchema.parse(args); + const membership = await this.client.updateMembership(validatedArgs.id, validatedArgs.roleIds); + + return { + content: [ + { + type: 'text', + text: `Membership updated successfully:\n\nID: ${membership.id}\nUser: ${membership._links.principal.title || 'N/A'}\nProject: ${membership._links.project.title || 'N/A'}\nRoles: ${membership._links.roles.map((r: any) => r.title).join(', ') || 'N/A'}\nUpdated: ${membership.updatedAt || 'N/A'}`, + }, + ], + }; + } + + private async handleDeleteMembership(args: any) { + const validatedArgs = DeleteMembershipArgsSchema.parse(args); + await this.client.deleteMembership(validatedArgs.id); + + return { + content: [ + { + type: 'text', + text: `Membership with ID ${validatedArgs.id} has been deleted successfully.`, + }, + ], + }; + } + + // Role handlers + private async handleGetRoles(args: any) { + const validatedArgs = GetRolesArgsSchema.parse(args); + const queryParams: any = {}; + if (validatedArgs.offset !== undefined) queryParams.offset = validatedArgs.offset; + if (validatedArgs.pageSize !== undefined) queryParams.pageSize = validatedArgs.pageSize; + if (validatedArgs.filters !== undefined) queryParams.filters = validatedArgs.filters; + if (validatedArgs.sortBy !== undefined) queryParams.sortBy = validatedArgs.sortBy; + + const roles = await this.client.getRoles(queryParams); + + return { + content: [ + { + type: 'text', + text: `Found ${roles.total} roles:\n\n${roles._embedded.elements.map((role: any) => + `ID: ${role.id}\nName: ${role.name}\n` + ).join('\n')}`, + }, + ], + }; + } + + private async handleGetRole(args: any) { + const validatedArgs = GetRoleArgsSchema.parse(args); + const role = await this.client.getRole(validatedArgs.id); + + return { + content: [ + { + type: 'text', + text: `Role Details:\n\nID: ${role.id}\nName: ${role.name}`, + }, + ], + }; + } + + // Upload Attachment handlers + private async handleUploadAttachment(args: any) { + const validatedArgs = UploadAttachmentArgsSchema.parse(args); + const attachment = await this.client.uploadAttachment( + validatedArgs.workPackageId, + validatedArgs.filePath, + validatedArgs.description + ); + + return { + content: [ + { + type: 'text', + text: `File uploaded successfully:\n\nFile: ${attachment.fileName}\nPath: ${validatedArgs.filePath}\nSize: ${attachment.fileSize} bytes\nAttachment ID: ${attachment.id}\nWork Package ID: ${validatedArgs.workPackageId}`, + }, + ], + }; + } + + private async handleGetAttachments(args: any) { + const validatedArgs = GetAttachmentsArgsSchema.parse(args); + const result = await this.client.getWorkPackageAttachments(validatedArgs.workPackageId); + + if (result.total === 0) { + return { + content: [ + { + type: 'text', + text: `No attachments found for work package ID ${validatedArgs.workPackageId}.`, + }, + ], + }; + } + + return { + content: [ + { + type: 'text', + text: `Found ${result.total} attachments for work package ID ${validatedArgs.workPackageId}:\n\n${result._embedded.elements + .map((attachment: any) => { + // Handle description which can be string or object + let descriptionText = 'No description'; + if (attachment.description) { + if (typeof attachment.description === 'string') { + descriptionText = attachment.description; + } else if (typeof attachment.description === 'object' && attachment.description.raw) { + descriptionText = attachment.description.raw; + } + } + + return `- ${attachment.fileName} (ID: ${attachment.id})\n Size: ${attachment.fileSize} bytes\n Description: ${descriptionText}`; + }) + .join('\n\n')}`, + }, + ], + }; + } + + private async handleDownloadAttachment(args: any) { + const validatedArgs = DownloadAttachmentArgsSchema.parse(args); + const { filePath, fileName, fileSize } = await this.client.downloadAttachment( + validatedArgs.attachmentId, + validatedArgs.outputPath + ); + + return { + content: [ + { + type: 'text', + text: `Attachment downloaded successfully:\n\nFile: ${fileName}\nSize: ${fileSize} bytes\nSaved to: ${filePath}`, + }, + ], + }; + } + + // Utility handlers + private async handleTestConnection() { + const isConnected = await this.client.testConnection(); + + return { + content: [ + { + type: 'text', + text: isConnected + ? 'Connection to OpenProject API successful!' + : 'Failed to connect to OpenProject API. Please check your configuration.', + }, + ], + }; + } + + private async handleGetApiInfo() { + const apiInfo = await this.client.getApiInfo(); + + return { + content: [ + { + type: 'text', + text: `OpenProject API Information:\n\n${JSON.stringify(apiInfo, null, 2)}`, + }, + ], + }; } } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 4ef1381..44412e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -123,12 +123,24 @@ class OpenProjectMCPServer { } // Start the server -if (import.meta.url === `file://${process.argv[1]}`) { - const server = new OpenProjectMCPServer(); - server.start().catch((error) => { - console.error('Server startup failed:', error); - process.exit(1); - }); +let isMainModule = false; +// handle windows paths +if (process.platform === 'win32') { + const argv1 = process.argv[1] as string | undefined; // Type assertion for TypeScript strict null checks; argv[1] is the script path when run directly. + if (argv1) { + const normalizedMetaUrl = import.meta.url.replace(/\\/g, '/'); + const normalizedArgv = `file:///${argv1.replace(/\\/g, '/')}`; + isMainModule = normalizedMetaUrl === normalizedArgv; + } +} else { + isMainModule = import.meta.url === `file://${process.argv[1] || ''}`; +} +if (isMainModule) { + const server = new OpenProjectMCPServer(); + server.start().catch((error) => { + console.error('Server startup failed:', error); + process.exit(1); + }); } export { OpenProjectMCPServer }; \ No newline at end of file diff --git a/src/tools/index.ts b/src/tools/index.ts index f371d80..3c51653 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -57,6 +57,18 @@ const CreateWorkPackageArgsSchema = z.object({ estimatedTime: z.string().optional(), }); +const CreateTaskArgsSchema = z.object({ + subject: z.string(), + description: z.string().optional(), + projectId: z.number(), + statusId: z.number().optional(), + priorityId: z.number().optional(), + assigneeId: z.number().optional(), + startDate: z.string().optional(), + dueDate: z.string().optional(), + estimatedTime: z.string().optional(), +}); + const UpdateWorkPackageArgsSchema = z.object({ id: z.number(), subject: z.string().optional(), @@ -70,6 +82,11 @@ const UpdateWorkPackageArgsSchema = z.object({ percentageDone: z.number().optional(), }); +const SetWorkPackageStatusArgsSchema = z.object({ + id: z.number(), + statusId: z.number(), +}); + const DeleteWorkPackageArgsSchema = z.object({ id: z.number(), }); @@ -100,6 +117,12 @@ const GetUsersArgsSchema = z.object({ filters: z.string().optional(), }); +const GetStatusesArgsSchema = z.object({ + offset: z.number().optional(), + pageSize: z.number().optional(), + filters: z.string().optional(), +}); + const GetTimeEntriesArgsSchema = z.object({ offset: z.number().optional(), pageSize: z.number().optional(), @@ -167,6 +190,63 @@ const RemoveBoardWidgetArgsSchema = z.object({ widgetId: z.number(), }); +// Membership and Role argument schemas +const GetMembershipsArgsSchema = z.object({ + offset: z.number().optional(), + pageSize: z.number().optional(), + filters: z.string().optional(), + sortBy: z.string().optional(), + projectId: z.number().optional(), +}); + +const GetMembershipArgsSchema = z.object({ + id: z.number(), +}); + +const CreateMembershipArgsSchema = z.object({ + projectId: z.number(), + userId: z.number(), + roleIds: z.array(z.number()), +}); + +const UpdateMembershipArgsSchema = z.object({ + id: z.number(), + roleIds: z.array(z.number()), +}); + +const DeleteMembershipArgsSchema = z.object({ + id: z.number(), +}); + +const GetRolesArgsSchema = z.object({ + offset: z.number().optional(), + pageSize: z.number().optional(), + filters: z.string().optional(), + sortBy: z.string().optional(), +}); + +const GetRoleArgsSchema = z.object({ + id: z.number(), +}); + +// Upload Attachment argument schema +const UploadAttachmentArgsSchema = z.object({ + workPackageId: z.number(), + filePath: z.string(), + description: z.string().optional(), +}); + +// Get Attachments argument schema +const GetAttachmentsArgsSchema = z.object({ + workPackageId: z.number(), +}); + +// Download Attachment argument schema +const DownloadAttachmentArgsSchema = z.object({ + attachmentId: z.number(), + outputPath: z.string().optional(), +}); + export function createOpenProjectTools(): Tool[] { return [ // Project tools @@ -374,6 +454,52 @@ export function createOpenProjectTools(): Tool[] { required: ['subject', 'projectId', 'typeId'], }, }, + { + name: 'create_task', + description: 'Create a new task in OpenProject (automatically sets type to Task)', + inputSchema: { + type: 'object', + properties: { + subject: { + type: 'string', + description: 'Task subject/title', + }, + description: { + type: 'string', + description: 'Task description', + }, + projectId: { + type: 'number', + description: 'Project ID', + }, + statusId: { + type: 'number', + description: 'Status ID', + }, + priorityId: { + type: 'number', + description: 'Priority ID', + }, + assigneeId: { + type: 'number', + description: 'Assignee user ID', + }, + startDate: { + type: 'string', + description: 'Start date (YYYY-MM-DD)', + }, + dueDate: { + type: 'string', + description: 'Due date (YYYY-MM-DD)', + }, + estimatedTime: { + type: 'string', + description: 'Estimated time (e.g., "PT8H" for 8 hours)', + }, + }, + required: ['subject', 'projectId'], + }, + }, { name: 'update_work_package', description: 'Update an existing work package', @@ -424,6 +550,24 @@ export function createOpenProjectTools(): Tool[] { required: ['id'], }, }, + { + name: 'set_work_package_status', + description: 'Set work package status. STOP on error. Do not investigate.', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'number', + description: 'Work package ID', + }, + statusId: { + type: 'number', + description: 'Status ID (required).', + }, + }, + required: ['id', 'statusId'], + }, + }, { name: 'delete_work_package', description: 'Delete a work package from OpenProject', @@ -543,6 +687,29 @@ export function createOpenProjectTools(): Tool[] { }, }, + // Status tools + { + name: 'get_statuses', + description: 'Get a list of available work package statuses from OpenProject', + inputSchema: { + type: 'object', + properties: { + offset: { + type: 'number', + description: 'Offset for pagination (default: 0)', + }, + pageSize: { + type: 'number', + description: 'Number of items per page (default: 20)', + }, + filters: { + type: 'string', + description: 'JSON string of filters to apply', + }, + }, + }, + }, + // Time Entry tools { name: 'get_time_entries', @@ -808,6 +975,212 @@ export function createOpenProjectTools(): Tool[] { required: ['boardId', 'widgetId'], }, }, + + // Membership tools + { + name: 'get_memberships', + description: 'Get a list of project memberships from OpenProject', + inputSchema: { + type: 'object', + properties: { + offset: { + type: 'number', + description: 'Offset for pagination (default: 0)', + }, + pageSize: { + type: 'number', + description: 'Number of items per page (default: 20)', + }, + filters: { + type: 'string', + description: 'JSON string of filters to apply', + }, + sortBy: { + type: 'string', + description: 'Sort criteria (e.g., "created_at:desc")', + }, + projectId: { + type: 'number', + description: 'Filter by project ID', + }, + }, + }, + }, + { + name: 'get_membership', + description: 'Get a specific project membership by ID', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'number', + description: 'Membership ID', + }, + }, + required: ['id'], + }, + }, + { + name: 'create_membership', + description: 'Create a new project membership (add user to project)', + inputSchema: { + type: 'object', + properties: { + projectId: { + type: 'number', + description: 'Project ID', + }, + userId: { + type: 'number', + description: 'User ID to add to the project', + }, + roleIds: { + type: 'array', + items: { + type: 'number', + }, + description: 'Array of role IDs to assign to the user', + }, + }, + required: ['projectId', 'userId', 'roleIds'], + }, + }, + { + name: 'update_membership', + description: 'Update an existing project membership (modify user roles)', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'number', + description: 'Membership ID', + }, + roleIds: { + type: 'array', + items: { + type: 'number', + }, + description: 'Array of role IDs to assign to the user', + }, + }, + required: ['id', 'roleIds'], + }, + }, + { + name: 'delete_membership', + description: 'Delete a project membership (remove user from project)', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'number', + description: 'Membership ID', + }, + }, + required: ['id'], + }, + }, + + // Role tools + { + name: 'get_roles', + description: 'Get a list of available roles from OpenProject', + inputSchema: { + type: 'object', + properties: { + offset: { + type: 'number', + description: 'Offset for pagination (default: 0)', + }, + pageSize: { + type: 'number', + description: 'Number of items per page (default: 20)', + }, + filters: { + type: 'string', + description: 'JSON string of filters to apply', + }, + sortBy: { + type: 'string', + description: 'Sort criteria (e.g., "name:asc")', + }, + }, + }, + }, + { + name: 'get_role', + description: 'Get a specific role by ID', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'number', + description: 'Role ID', + }, + }, + required: ['id'], + }, + }, + + // Upload Attachment tool + { + name: 'upload_attachment', + description: 'Upload a file attachment to a work package', + inputSchema: { + type: 'object', + properties: { + workPackageId: { + type: 'number', + description: 'Work package ID to attach the file to', + }, + filePath: { + type: 'string', + description: 'Path to the file to upload (absolute or relative to working directory)', + }, + description: { + type: 'string', + description: 'Optional description for the attachment', + }, + }, + required: ['workPackageId', 'filePath'], + }, + }, + + // Get Attachments tool + { + name: 'get_attachments', + description: 'Get all attachments for a work package', + inputSchema: { + type: 'object', + properties: { + workPackageId: { + type: 'number', + description: 'Work package ID to get attachments for', + }, + }, + required: ['workPackageId'], + }, + }, + + // Download Attachment tool + { + name: 'download_attachment', + description: 'Download an attachment by ID to local disk', + inputSchema: { + type: 'object', + properties: { + attachmentId: { + type: 'number', + description: 'Attachment ID to download', + }, + outputPath: { + type: 'string', + description: 'Optional path to save the file. Defaults to ./downloads/', + }, + }, + required: ['attachmentId'], + }, + }, ]; } @@ -821,13 +1194,16 @@ export { GetWorkPackagesArgsSchema, GetWorkPackageArgsSchema, CreateWorkPackageArgsSchema, + CreateTaskArgsSchema, UpdateWorkPackageArgsSchema, + SetWorkPackageStatusArgsSchema, DeleteWorkPackageArgsSchema, SetWorkPackageParentArgsSchema, RemoveWorkPackageParentArgsSchema, GetWorkPackageChildrenArgsSchema, SearchArgsSchema, GetUsersArgsSchema, + GetStatusesArgsSchema, GetTimeEntriesArgsSchema, CreateTimeEntryArgsSchema, GetBoardsArgsSchema, @@ -837,4 +1213,14 @@ export { DeleteBoardArgsSchema, AddBoardWidgetArgsSchema, RemoveBoardWidgetArgsSchema, + GetMembershipsArgsSchema, + GetMembershipArgsSchema, + CreateMembershipArgsSchema, + UpdateMembershipArgsSchema, + DeleteMembershipArgsSchema, + GetRolesArgsSchema, + GetRoleArgsSchema, + UploadAttachmentArgsSchema, + GetAttachmentsArgsSchema, + DownloadAttachmentArgsSchema, }; \ No newline at end of file diff --git a/src/types/openproject.ts b/src/types/openproject.ts index e79cf36..b0a5d87 100644 --- a/src/types/openproject.ts +++ b/src/types/openproject.ts @@ -238,6 +238,162 @@ export const CollectionResponseSchema = z.object({ _links: z.record(z.any()).optional(), }); +// Status schema for work package statuses +export const StatusSchema = z.object({ + _type: z.literal('Status'), + id: z.number(), + name: z.string(), + isClosed: z.boolean(), + color: z.string().optional(), + isDefault: z.boolean().optional(), + isReadonly: z.boolean().optional(), + excludedFromTotals: z.boolean().optional(), + defaultDoneRatio: z.number().optional(), + position: z.number().optional(), + _links: z.record(z.any()).optional(), +}); + +// Status collection response schema +export const StatusCollectionResponseSchema = z.object({ + _type: z.literal('Collection'), + total: z.number(), + count: z.number(), + pageSize: z.number().optional(), + offset: z.number().optional(), + _embedded: z.object({ + elements: z.array(StatusSchema), + }), + _links: z.record(z.any()).optional(), +}); + +// Role schemas +export const RoleSchema = z.object({ + _type: z.literal('Role'), + id: z.number(), + name: z.string(), + _links: z.object({ + self: z.object({ + href: z.string(), + title: z.string(), + }), + }), +}); + +// Attachment schemas +export const AttachmentSchema = z.object({ + _type: z.literal('Attachment'), + id: z.number(), + fileName: z.string(), + fileSize: z.number(), + description: z.union([ + z.string(), + z.object({ + format: z.string(), + raw: z.string(), + html: z.string().optional(), + }) + ]).optional(), + _links: z.object({ + self: z.object({ + href: z.string(), + title: z.string(), + }), + author: z.object({ + href: z.string(), + title: z.string(), + }), + container: z.object({ + href: z.string(), + title: z.string(), + }), + staticDownloadLocation: z.object({ + href: z.string(), + }), + downloadLocation: z.object({ + href: z.string(), + }), + delete: z.object({ + href: z.string(), + method: z.string(), + }), + }), +}); + +// Attachment collection response schema +export const AttachmentCollectionResponseSchema = z.object({ + _type: z.literal('Collection'), + total: z.number(), + count: z.number(), + pageSize: z.number().optional(), + offset: z.number().optional(), + _embedded: z.object({ + elements: z.array(AttachmentSchema), + }), + _links: z.record(z.any()).optional(), +}); + +// Membership schemas +export const MembershipSchema = z.object({ + _type: z.literal('Membership'), + id: z.number(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + _links: z.object({ + self: z.object({ + href: z.string(), + title: z.string().optional(), + }), + schema: z.object({ + href: z.string(), + }).optional(), + update: z.object({ + href: z.string(), + method: z.string(), + }).optional(), + updateImmediately: z.object({ + href: z.string(), + method: z.string(), + }).optional(), + project: z.object({ + href: z.string(), + title: z.string(), + }), + principal: z.object({ + href: z.string(), + title: z.string(), + }), + roles: z.array(z.object({ + href: z.string(), + title: z.string(), + })), + }), +}); + +// Collection response schemas for memberships and roles +export const MembershipCollectionResponseSchema = z.object({ + _type: z.literal('Collection'), + total: z.number(), + count: z.number(), + pageSize: z.number().optional(), + offset: z.number().optional(), + _embedded: z.object({ + elements: z.array(MembershipSchema), + }), + _links: z.record(z.any()).optional(), +}); + +export const RoleCollectionResponseSchema = z.object({ + _type: z.literal('Collection'), + total: z.number(), + count: z.number(), + pageSize: z.number().optional(), + offset: z.number().optional(), + _embedded: z.object({ + elements: z.array(RoleSchema), + }), + _links: z.record(z.any()).optional(), +}); + // API Configuration export const OpenProjectConfigSchema = z.object({ baseUrl: z.string().url(), @@ -254,7 +410,15 @@ export type TimeEntry = z.infer; export type GridWidget = z.infer; export type Grid = z.infer; export type Board = z.infer; +export type Status = z.infer; +export type StatusCollectionResponse = z.infer; export type CollectionResponse = z.infer; +export type Membership = z.infer; +export type Role = z.infer; +export type Attachment = z.infer; +export type AttachmentCollectionResponse = z.infer; +export type MembershipCollectionResponse = z.infer; +export type RoleCollectionResponse = z.infer; export type OpenProjectConfig = z.infer; // Query parameters for API calls @@ -266,11 +430,10 @@ export interface QueryParams { groupBy?: string; showSums?: boolean; } - // Error response from OpenProject API export interface OpenProjectError { _type: 'Error'; errorIdentifier: string; message: string; details?: Record; -} \ No newline at end of file +} diff --git a/test.env b/test.env new file mode 100644 index 0000000..6c7e9b8 --- /dev/null +++ b/test.env @@ -0,0 +1,5 @@ +OPENPROJECT_BASE_URL=https://your-instance.openproject.com +OPENPROJECT_API_KEY=your-api-key-here +LOG_LEVEL=DEBUG +MCP_SERVER_NAME=openproject-mcp-server +MCP_SERVER_VERSION=1.0.0