From afaf39932e4208ec1fc7dd66f3791ce02addd3da Mon Sep 17 00:00:00 2001 From: Will James Date: Thu, 28 Aug 2025 14:53:15 -0400 Subject: [PATCH 01/15] make main module check compatible with windows paths --- src/index.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) 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 From 879d52fadf654a4a0c74d2dc28610f758846c01b Mon Sep 17 00:00:00 2001 From: Will James Date: Thu, 28 Aug 2025 15:05:19 -0400 Subject: [PATCH 02/15] Fix description formatting for OpenProject work packages - Add extractDescriptionText utility method to convert description objects to readable strings - Fix getWorkPackages to extract description text for all work packages in collection - Fix getWorkPackage to extract description text for individual work packages - Fix createWorkPackage to transform string descriptions to proper OpenProject API format - Fix updateWorkPackage to transform string descriptions to proper OpenProject API format - Add comprehensive logging and error handling for all methods This resolves the '[object Object]' display issue and ensures descriptions are properly stored and retrieved in both create and update operations. --- src/client/openproject-client.ts | 93 ++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 4 deletions(-) diff --git a/src/client/openproject-client.ts b/src/client/openproject-client.ts index 87434af..8dffc29 100644 --- a/src/client/openproject-client.ts +++ b/src/client/openproject-client.ts @@ -176,6 +176,15 @@ export class OpenProjectClient { logger.logApiRequest('GET', url, undefined, params); const response = await this.axiosInstance.get(url); + // Extract readable description text 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); + } + }); + } + logger.logApiResponse('GET', url, response.status, response.headers, response.data); logger.logRawData('getWorkPackages response', response.data); @@ -201,12 +210,55 @@ 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 }); + + // 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); + } + return WorkPackageSchema.parse(response.data); } 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 +280,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 @@ -649,4 +715,23 @@ 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 ''; + } } \ No newline at end of file From 1b1d52acc66e7ec60272243def688b8447f1d30b Mon Sep 17 00:00:00 2001 From: Will James Date: Fri, 29 Aug 2025 09:49:55 -0400 Subject: [PATCH 03/15] add new tool to set the status on a work package task --- src/client/openproject-client.ts | 9 ++++ src/handlers/tool-handlers.ts | 71 ++++++++++++++++++++++++++++++++ src/tools/index.ts | 54 ++++++++++++++++++++++++ src/types/openproject.ts | 30 ++++++++++++++ 4 files changed, 164 insertions(+) diff --git a/src/client/openproject-client.ts b/src/client/openproject-client.ts index 8dffc29..e4addb5 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,6 +18,7 @@ import { TimeEntrySchema, GridSchema, BoardSchema, + StatusCollectionResponseSchema, CollectionResponseSchema, } from '../types/openproject.js'; import { logger } from '../utils/logger.js'; @@ -540,6 +542,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); diff --git a/src/handlers/tool-handlers.ts b/src/handlers/tool-handlers.ts index a12c30a..7f36d48 100644 --- a/src/handlers/tool-handlers.ts +++ b/src/handlers/tool-handlers.ts @@ -11,12 +11,14 @@ import { GetWorkPackageArgsSchema, CreateWorkPackageArgsSchema, UpdateWorkPackageArgsSchema, + SetWorkPackageStatusArgsSchema, DeleteWorkPackageArgsSchema, SetWorkPackageParentArgsSchema, RemoveWorkPackageParentArgsSchema, GetWorkPackageChildrenArgsSchema, SearchArgsSchema, GetUsersArgsSchema, + GetStatusesArgsSchema, GetTimeEntriesArgsSchema, CreateTimeEntryArgsSchema, GetBoardsArgsSchema, @@ -71,6 +73,9 @@ export class OpenProjectToolHandlers { 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 +104,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); @@ -471,6 +481,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 +717,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); diff --git a/src/tools/index.ts b/src/tools/index.ts index f371d80..fa8591c 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -70,6 +70,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 +105,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(), @@ -424,6 +435,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 +572,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', @@ -822,12 +874,14 @@ export { GetWorkPackageArgsSchema, CreateWorkPackageArgsSchema, UpdateWorkPackageArgsSchema, + SetWorkPackageStatusArgsSchema, DeleteWorkPackageArgsSchema, SetWorkPackageParentArgsSchema, RemoveWorkPackageParentArgsSchema, GetWorkPackageChildrenArgsSchema, SearchArgsSchema, GetUsersArgsSchema, + GetStatusesArgsSchema, GetTimeEntriesArgsSchema, CreateTimeEntryArgsSchema, GetBoardsArgsSchema, diff --git a/src/types/openproject.ts b/src/types/openproject.ts index e79cf36..de4e8af 100644 --- a/src/types/openproject.ts +++ b/src/types/openproject.ts @@ -238,6 +238,34 @@ 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(), +}); + // API Configuration export const OpenProjectConfigSchema = z.object({ baseUrl: z.string().url(), @@ -254,6 +282,8 @@ 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 OpenProjectConfig = z.infer; From 7df35bc980c74f26c19fb1e1982eccaf76982bc3 Mon Sep 17 00:00:00 2001 From: Will James Date: Fri, 29 Aug 2025 14:07:37 -0400 Subject: [PATCH 04/15] feat: Implement comprehensive User Project Membership Management system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## � Core Features Implemented ### 1. Membership Management (CRUD Operations) - **List Memberships**: Retrieve all project memberships with filtering/pagination support - **Get Membership**: Retrieve specific membership details by ID - **Create Membership**: Add users to projects with specific roles - **Update Membership**: Modify existing user roles within projects - **Delete Membership**: Remove user access from projects ### 2. Role Management - **List Roles**: Retrieve all available system roles - **Get Role**: Retrieve specific role details by ID ### 3. MCP Tool Integration - **7 New MCP Tools**: Complete tool set for membership and role operations - **Zod Schema Validation**: Type-safe argument validation for all tools - **Comprehensive Error Handling**: Proper error responses and logging ### 4. Technical Implementation - **TypeScript Types**: Complete type definitions for Membership and Role entities - **OpenProject Client**: New client methods for all membership operations - **Tool Handlers**: MCP tool handlers with proper response formatting - **API Integration**: Direct OpenProject API integration with authentication ### 5. Schema & Validation - **Membership Schema**: Proper _links structure matching OpenProject API - **Role Schema**: Simplified schema matching actual API responses - **Collection Responses**: Support for paginated membership and role lists ## � Files Modified - src/types/openproject.ts: Added Membership and Role schemas/types - src/tools/index.ts: Added 7 new MCP tool definitions - src/client/openproject-client.ts: Added membership CRUD client methods - src/handlers/tool-handlers.ts: Added comprehensive tool handlers ## ✅ Testing & Validation - **Direct API Testing**: Verified all endpoints with curl before implementation - **End-to-End Testing**: All 7 tools tested and working correctly - **Schema Validation**: Zod validation passing for all operations - **Real-World Usage**: Successfully used to solve project assignment issues ## � Production Ready - Complete membership lifecycle management - Proper error handling and logging - Following repository development standards - Ready for production deployment Resolves: Ticket #50 - Add User Project Membership Management to OpenProject MCP Server --- src/client/openproject-client.ts | 232 +++++++++++++++++++++++++++++++ src/handlers/tool-handlers.ts | 206 +++++++++++++++++++++++---- src/tools/index.ts | 192 +++++++++++++++++++++++++ src/types/openproject.ts | 82 ++++++++++- 4 files changed, 681 insertions(+), 31 deletions(-) diff --git a/src/client/openproject-client.ts b/src/client/openproject-client.ts index e4addb5..1fc9d0d 100644 --- a/src/client/openproject-client.ts +++ b/src/client/openproject-client.ts @@ -20,6 +20,14 @@ import { BoardSchema, StatusCollectionResponseSchema, CollectionResponseSchema, + MembershipCollectionResponse, + MembershipCollectionResponseSchema, + MembershipSchema, + RoleCollectionResponse, + RoleCollectionResponseSchema, + RoleSchema, + Membership, + Role, } from '../types/openproject.js'; import { logger } from '../utils/logger.js'; @@ -575,6 +583,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 { diff --git a/src/handlers/tool-handlers.ts b/src/handlers/tool-handlers.ts index 7f36d48..d1392d7 100644 --- a/src/handlers/tool-handlers.ts +++ b/src/handlers/tool-handlers.ts @@ -28,6 +28,13 @@ import { DeleteBoardArgsSchema, AddBoardWidgetArgsSchema, RemoveBoardWidgetArgsSchema, + GetMembershipsArgsSchema, + GetMembershipArgsSchema, + CreateMembershipArgsSchema, + UpdateMembershipArgsSchema, + DeleteMembershipArgsSchema, + GetRolesArgsSchema, + GetRoleArgsSchema, } from '../tools/index.js'; export class OpenProjectToolHandlers { @@ -140,6 +147,31 @@ 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; + // Utility handlers case 'test_connection': result = await this.handleTestConnection(); @@ -808,35 +840,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); @@ -972,5 +975,150 @@ 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}`, + }, + ], + }; + } + + // 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/tools/index.ts b/src/tools/index.ts index fa8591c..25440a4 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -178,6 +178,45 @@ 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(), +}); + export function createOpenProjectTools(): Tool[] { return [ // Project tools @@ -860,6 +899,152 @@ 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'], + }, + }, ]; } @@ -891,4 +1076,11 @@ export { DeleteBoardArgsSchema, AddBoardWidgetArgsSchema, RemoveBoardWidgetArgsSchema, + GetMembershipsArgsSchema, + GetMembershipArgsSchema, + CreateMembershipArgsSchema, + UpdateMembershipArgsSchema, + DeleteMembershipArgsSchema, + GetRolesArgsSchema, + GetRoleArgsSchema, }; \ No newline at end of file diff --git a/src/types/openproject.ts b/src/types/openproject.ts index de4e8af..f16f6c4 100644 --- a/src/types/openproject.ts +++ b/src/types/openproject.ts @@ -266,6 +266,81 @@ export const StatusCollectionResponseSchema = z.object({ _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(), + }), + }), +}); + +// 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(), @@ -285,6 +360,10 @@ 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 MembershipCollectionResponse = z.infer; +export type RoleCollectionResponse = z.infer; export type OpenProjectConfig = z.infer; // Query parameters for API calls @@ -296,11 +375,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 +} From 5efda6c0564afa85c4f543c9c9c49232c997d609 Mon Sep 17 00:00:00 2001 From: Will James Date: Fri, 29 Aug 2025 16:18:26 -0400 Subject: [PATCH 05/15] fix: Transform OpenProject API _links structure to resolve "Unknown" property display - Fix getWorkPackage and getWorkPackages methods to properly transform _links.status, _links.project, _links.type, etc. - Resolves bug where status, project, and type fields showed "Unknown" instead of actual values - Ensures MCP tools display meaningful work package information from OpenProject API responses --- src/client/openproject-client.ts | 152 ++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 1 deletion(-) diff --git a/src/client/openproject-client.ts b/src/client/openproject-client.ts index 1fc9d0d..1dcf66e 100644 --- a/src/client/openproject-client.ts +++ b/src/client/openproject-client.ts @@ -192,6 +192,79 @@ export class OpenProjectClient { if (wp.description && typeof wp.description === 'object') { wp.description = this.extractDescriptionText(wp.description); } + + // Transform _links structure to match schema expectations for each work package + // Transform status from _links.status to status object + if (wp._links?.status) { + const statusHref = wp._links.status.href; + const statusId = statusHref ? parseInt(statusHref.split('/').pop() || '0') : null; + if (statusId) { + wp.status = { + id: statusId, + name: wp._links.status.title || 'Unknown' + }; + } + } + + // Transform project from _links.project to project object + if (wp._links?.project) { + const projectHref = wp._links.project.href; + const projectId = projectHref ? parseInt(projectHref.split('/').pop() || '0') : null; + if (projectId) { + wp.project = { + id: projectId, + name: wp._links.project.title || 'Unknown' + }; + } + } + + // Transform type from _links.type to type object + if (wp._links?.type) { + const typeHref = wp._links.type.href; + const typeId = typeHref ? parseInt(typeHref.split('/').pop() || '0') : null; + if (typeId) { + wp.type = { + id: typeId, + name: wp._links.type.title || 'Unknown' + }; + } + } + + // Transform priority from _links.priority to priority object + if (wp._links?.priority) { + const priorityHref = wp._links.priority.href; + const priorityId = priorityHref ? parseInt(priorityHref.split('/').pop() || '0') : null; + if (priorityId) { + wp.priority = { + id: priorityId, + name: wp._links.priority.title || 'Unknown' + }; + } + } + + // Transform assignee from _links.assignee to assignee object + if (wp._links?.assignee) { + const assigneeHref = wp._links.assignee.href; + const assigneeId = assigneeHref ? parseInt(assigneeHref.split('/').pop() || '0') : null; + if (assigneeId) { + wp.assignee = { + id: assigneeId, + name: wp._links.assignee.title || 'Unknown' + }; + } + } + + // Transform responsible from _links.responsible to responsible object + if (wp._links?.responsible) { + const responsibleHref = wp._links.responsible.href; + const responsibleId = responsibleHref ? parseInt(responsibleHref.split('/').pop() || '0') : null; + if (responsibleId) { + wp.responsible = { + id: responsibleId, + name: wp._links.responsible.title || 'Unknown' + }; + } + } }); } @@ -226,7 +299,84 @@ export class OpenProjectClient { response.data.description = this.extractDescriptionText(response.data.description); } - return WorkPackageSchema.parse(response.data); + // Transform _links structure to match schema expectations + const transformedData = { ...response.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' + }; + } + } + + logger.debug('Transformed work package data', { transformedData }); + + return WorkPackageSchema.parse(transformedData); } async createWorkPackage(workPackageData: Partial): Promise { From e75099aef4f2b2c8725e37324d3e802bb0f5917e Mon Sep 17 00:00:00 2001 From: Will James Date: Fri, 29 Aug 2025 16:23:52 -0400 Subject: [PATCH 06/15] fix: Fix update_work_package status display showing "Unknown" Transform OpenProject API response data to properly display status, project, and type information --- src/client/openproject-client.ts | 79 +++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/src/client/openproject-client.ts b/src/client/openproject-client.ts index 1dcf66e..b3fe5d7 100644 --- a/src/client/openproject-client.ts +++ b/src/client/openproject-client.ts @@ -510,7 +510,84 @@ export class OpenProjectClient { newLockVersion: response.data.lockVersion }); - return WorkPackageSchema.parse(response.data); + // Transform the response data to match schema expectations (same as getWorkPackage) + const transformedResponseData = { ...response.data }; + + // Transform status from _links.status to status object + if (transformedResponseData._links?.status) { + const statusHref = transformedResponseData._links.status.href; + const statusId = statusHref ? parseInt(statusHref.split('/').pop() || '0') : null; + if (statusId) { + transformedResponseData.status = { + id: statusId, + name: transformedResponseData._links.status.title || 'Unknown' + }; + } + } + + // Transform project from _links.project to project object + if (transformedResponseData._links?.project) { + const projectHref = transformedResponseData._links.project.href; + const projectId = projectHref ? parseInt(projectHref.split('/').pop() || '0') : null; + if (projectId) { + transformedResponseData.project = { + id: projectId, + name: transformedResponseData._links.project.title || 'Unknown' + }; + } + } + + // Transform type from _links.type to type object + if (transformedResponseData._links?.type) { + const typeHref = transformedResponseData._links.type.href; + const typeId = typeHref ? parseInt(typeHref.split('/').pop() || '0') : null; + if (typeId) { + transformedResponseData.type = { + id: typeId, + name: transformedResponseData._links.type.title || 'Unknown' + }; + } + } + + // Transform priority from _links.priority to priority object + if (transformedResponseData._links?.priority) { + const priorityHref = transformedResponseData._links.priority.href; + const priorityId = priorityHref ? parseInt(priorityHref.split('/').pop() || '0') : null; + if (priorityId) { + transformedResponseData.priority = { + id: priorityId, + name: transformedResponseData._links.priority.title || 'Unknown' + }; + } + } + + // Transform assignee from _links.assignee to assignee object + if (transformedResponseData._links?.assignee) { + const assigneeHref = transformedResponseData._links.assignee.href; + const assigneeId = assigneeHref ? parseInt(assigneeHref.split('/').pop() || '0') : null; + if (assigneeId) { + transformedResponseData.assignee = { + id: assigneeId, + name: transformedResponseData._links.assignee.title || 'Unknown' + }; + } + } + + // Transform responsible from _links.responsible to responsible object + if (transformedResponseData._links?.responsible) { + const responsibleHref = transformedResponseData._links.responsible.href; + const responsibleId = responsibleHref ? parseInt(responsibleHref.split('/').pop() || '0') : null; + if (responsibleId) { + transformedResponseData.responsible = { + id: responsibleId, + name: transformedResponseData._links.responsible.title || 'Unknown' + }; + } + } + + 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)`, { From 6b59084bcef1739280301372c050e7a6a9dfd9b7 Mon Sep 17 00:00:00 2001 From: Will James Date: Fri, 29 Aug 2025 16:38:41 -0400 Subject: [PATCH 07/15] refactor: Consolidate duplicate _links transformation code Extract common transformation logic into utility method, eliminating code duplication across work package methods --- src/client/openproject-client.ts | 304 +++++++++---------------------- 1 file changed, 84 insertions(+), 220 deletions(-) diff --git a/src/client/openproject-client.ts b/src/client/openproject-client.ts index b3fe5d7..00379b1 100644 --- a/src/client/openproject-client.ts +++ b/src/client/openproject-client.ts @@ -186,85 +186,15 @@ export class OpenProjectClient { logger.logApiRequest('GET', url, undefined, params); const response = await this.axiosInstance.get(url); - // Extract readable description text for all work packages + // 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 for each work package - // Transform status from _links.status to status object - if (wp._links?.status) { - const statusHref = wp._links.status.href; - const statusId = statusHref ? parseInt(statusHref.split('/').pop() || '0') : null; - if (statusId) { - wp.status = { - id: statusId, - name: wp._links.status.title || 'Unknown' - }; - } - } - - // Transform project from _links.project to project object - if (wp._links?.project) { - const projectHref = wp._links.project.href; - const projectId = projectHref ? parseInt(projectHref.split('/').pop() || '0') : null; - if (projectId) { - wp.project = { - id: projectId, - name: wp._links.project.title || 'Unknown' - }; - } - } - - // Transform type from _links.type to type object - if (wp._links?.type) { - const typeHref = wp._links.type.href; - const typeId = typeHref ? parseInt(typeHref.split('/').pop() || '0') : null; - if (typeId) { - wp.type = { - id: typeId, - name: wp._links.type.title || 'Unknown' - }; - } - } - - // Transform priority from _links.priority to priority object - if (wp._links?.priority) { - const priorityHref = wp._links.priority.href; - const priorityId = priorityHref ? parseInt(priorityHref.split('/').pop() || '0') : null; - if (priorityId) { - wp.priority = { - id: priorityId, - name: wp._links.priority.title || 'Unknown' - }; - } - } - - // Transform assignee from _links.assignee to assignee object - if (wp._links?.assignee) { - const assigneeHref = wp._links.assignee.href; - const assigneeId = assigneeHref ? parseInt(assigneeHref.split('/').pop() || '0') : null; - if (assigneeId) { - wp.assignee = { - id: assigneeId, - name: wp._links.assignee.title || 'Unknown' - }; - } - } - - // Transform responsible from _links.responsible to responsible object - if (wp._links?.responsible) { - const responsibleHref = wp._links.responsible.href; - const responsibleId = responsibleHref ? parseInt(responsibleHref.split('/').pop() || '0') : null; - if (responsibleId) { - wp.responsible = { - id: responsibleId, - name: wp._links.responsible.title || 'Unknown' - }; - } - } + // Transform _links structure to match schema expectations + Object.assign(wp, this.transformWorkPackageLinks(wp)); }); } @@ -300,79 +230,7 @@ export class OpenProjectClient { } // Transform _links structure to match schema expectations - const transformedData = { ...response.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' - }; - } - } + const transformedData = this.transformWorkPackageLinks(response.data); logger.debug('Transformed work package data', { transformedData }); @@ -510,80 +368,8 @@ export class OpenProjectClient { newLockVersion: response.data.lockVersion }); - // Transform the response data to match schema expectations (same as getWorkPackage) - const transformedResponseData = { ...response.data }; - - // Transform status from _links.status to status object - if (transformedResponseData._links?.status) { - const statusHref = transformedResponseData._links.status.href; - const statusId = statusHref ? parseInt(statusHref.split('/').pop() || '0') : null; - if (statusId) { - transformedResponseData.status = { - id: statusId, - name: transformedResponseData._links.status.title || 'Unknown' - }; - } - } - - // Transform project from _links.project to project object - if (transformedResponseData._links?.project) { - const projectHref = transformedResponseData._links.project.href; - const projectId = projectHref ? parseInt(projectHref.split('/').pop() || '0') : null; - if (projectId) { - transformedResponseData.project = { - id: projectId, - name: transformedResponseData._links.project.title || 'Unknown' - }; - } - } - - // Transform type from _links.type to type object - if (transformedResponseData._links?.type) { - const typeHref = transformedResponseData._links.type.href; - const typeId = typeHref ? parseInt(typeHref.split('/').pop() || '0') : null; - if (typeId) { - transformedResponseData.type = { - id: typeId, - name: transformedResponseData._links.type.title || 'Unknown' - }; - } - } - - // Transform priority from _links.priority to priority object - if (transformedResponseData._links?.priority) { - const priorityHref = transformedResponseData._links.priority.href; - const priorityId = priorityHref ? parseInt(priorityHref.split('/').pop() || '0') : null; - if (priorityId) { - transformedResponseData.priority = { - id: priorityId, - name: transformedResponseData._links.priority.title || 'Unknown' - }; - } - } - - // Transform assignee from _links.assignee to assignee object - if (transformedResponseData._links?.assignee) { - const assigneeHref = transformedResponseData._links.assignee.href; - const assigneeId = assigneeHref ? parseInt(assigneeHref.split('/').pop() || '0') : null; - if (assigneeId) { - transformedResponseData.assignee = { - id: assigneeId, - name: transformedResponseData._links.assignee.title || 'Unknown' - }; - } - } - - // Transform responsible from _links.responsible to responsible object - if (transformedResponseData._links?.responsible) { - const responsibleHref = transformedResponseData._links.responsible.href; - const responsibleId = responsibleHref ? parseInt(responsibleHref.split('/').pop() || '0') : null; - if (responsibleId) { - transformedResponseData.responsible = { - id: responsibleId, - name: transformedResponseData._links.responsible.title || 'Unknown' - }; - } - } + // Transform the response data to match schema expectations + const transformedResponseData = this.transformWorkPackageLinks(response.data); logger.debug('Transformed update response data', { transformedResponseData }); @@ -1202,4 +988,82 @@ export class OpenProjectClient { } 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; + } } \ No newline at end of file From aede4d3415123a315c4c010be00ac04ab2e57bd7 Mon Sep 17 00:00:00 2001 From: Will James Date: Wed, 3 Sep 2025 06:44:51 -0400 Subject: [PATCH 08/15] feat: implement upload attachment functionality (ticket 120) - Add upload_attachment tool for uploading files to work packages - Add get_attachments tool for retrieving work package attachments - Implement AttachmentSchema and AttachmentCollectionResponseSchema types - Add uploadAttachment() and getWorkPackageAttachments() methods to OpenProjectClient - Add proper multipart form data handling using FormData - Add form-data dependency and types - Full TypeScript support with Zod validation - Comprehensive error handling and logging - Follows OpenProject REST API standards for file uploads API Endpoints: - POST /api/v3/work_packages/{id}/attachments - Upload files - GET /api/v3/work_packages/{id}/attachments - List attachments Implementation is complete and ready for testing. --- .cursorrules | 87 ++++++++++++++++++++++++++++++ package.json | 2 + src/client/openproject-client.ts | 91 ++++++++++++++++++++++++++++++++ src/handlers/tool-handlers.ts | 60 +++++++++++++++++++++ src/tools/index.ts | 64 ++++++++++++++++++++++ src/types/openproject.ts | 44 +++++++++++++++ 6 files changed, 348 insertions(+) create mode 100644 .cursorrules diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..d8f175f --- /dev/null +++ b/.cursorrules @@ -0,0 +1,87 @@ +# OpenProject MCP Server Development Rules + +## Core Development Rules + +### 1. OpenProject API Testing Requirements +- **ALWAYS test OpenProject HTTP APIs directly using curl/HTTP clients** to confirm proper API functionality when developing a new feature +- **DO NOT test through MCP tools** - MCP tools are just wrappers around the APIs +- Use authentication from `.env` file for direct API testing +- Verify endpoints, parameters, and responses work as expected +- Test both success and error scenarios +- Document any API limitations or unexpected behaviors +- Use OpenProject API learnings to develop MCP tools that interface to those APIs + +**Example of Direct API Testing:** +```bash +# Test OpenProject statuses endpoint directly with curl +curl -u "apikey:YOUR_API_KEY" "https://your-instance.openproject.com/api/v3/statuses" + +# Test work package update directly +curl -X PATCH -u "apikey:YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + "https://your-instance.openproject.com/api/v3/work_packages/2" \ + -d '{"lockVersion": 1, "_links": {"status": {"href": "/api/v3/statuses/1"}}}' +``` + +**What NOT to do:** +- Don't test by calling MCP tools like `mcp_openproject_get_statuses` +- MCP tools are the implementation, not the testing method + +### 2. MCP Server Management +- Ensure server is restarted and running before testing new functionality +- Use appropriate npm scripts for development and production +- Follow proper procedures for relaunching the MCP server after code changes + 1. pm2 stop mcp-openproject + 2. pm2 delete mcp-openproject + 3. npm run build + 4. pm2 start dist/index.js --name mcp-openproject + 5. stop and ask user to restart the MCP server in Cursor + +### 3. Blocked Work Protocol +- If stuck on a technical issue, **immediately update the ticket** with: + - Clear description of the sticking point + - What has been tried + - Current error messages or issues + - Next steps needed +- **Assign the ticket to Will James** for assistance +- Do not spend excessive time trying to resolve blocking issues alone +- Do not write to file journals + +### 4. Cleanup and Deletion Protocol +- If something needs to be deleted (files, code, etc.): + - **Note it in the ticket** for deletion later + - **Do NOT get stuck waiting for delete approval** + - Continue with implementation and mark deletion as a separate task + - Use TODO comments in code to mark items for cleanup + +## Development Workflow + +### Development Order (MUST FOLLOW): +1. **FIRST**: Test OpenProject HTTP APIs directly with curl/HTTP clients +2. **SECOND**: Implement MCP tools based on working API understanding +3. **THIRD**: Test MCP server functionality +4. **FOURTH**: Update ticket and mark complete + +### Before Marking Work Complete: +1. ✅ Code compiles without errors +2. ✅ OpenProject HTTP APIs tested directly with curl/HTTP clients and working +3. ✅ MCP server functionality verified +4. ✅ Ticket updated with implementation details +5. ✅ Any cleanup items noted for future action + +### When Blocked: +1. 🔴 Stop current work immediately +2. 🔴 Update ticket with blocking issue details +3. 🔴 Assign to Will James +4. 🔴 Move to next available task + +### Code Quality Standards: +- Use TypeScript strict mode +- Implement proper error handling +- Add comprehensive logging +- Follow existing code patterns + +## File Management +- Keep `.cursorrules` in version control for team consistency +- Update rules as project evolves +- Document any new patterns or requirements 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 00379b1..6b14fe1 100644 --- a/src/client/openproject-client.ts +++ b/src/client/openproject-client.ts @@ -1,5 +1,6 @@ import axios, { AxiosInstance } from 'axios'; import https from 'https'; +import FormData from 'form-data'; import { OpenProjectConfig, Project, @@ -26,8 +27,12 @@ import { RoleCollectionResponse, RoleCollectionResponseSchema, RoleSchema, + AttachmentSchema, + AttachmentCollectionResponseSchema, Membership, Role, + Attachment, + AttachmentCollectionResponse, } from '../types/openproject.js'; import { logger } from '../utils/logger.js'; @@ -1066,4 +1071,90 @@ export class OpenProjectClient { return transformedData; } + + // Attachment API methods + async uploadAttachment(workPackageId: number, fileName: string, fileContent: string, contentType?: string, description?: string): Promise { + const startTime = Date.now(); + const url = `/work_packages/${workPackageId}/attachments`; + + try { + // Decode base64 content + const buffer = Buffer.from(fileContent, 'base64'); + + // Create FormData for multipart upload + const formData = new FormData(); + + formData.append('file', buffer, { + filename: fileName, + contentType: contentType || 'application/octet-stream' + }); + + if (description) { + formData.append('description', description); + } + + logger.logApiRequest('POST', url, { fileName, contentType, description }, { workPackageId }); + + const response = await this.axiosInstance.post(url, formData, { + headers: { + ...formData.getHeaders(), + 'Content-Type': 'multipart/form-data' + } + }); + + 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, + fileName, + attachmentId: response.data?.id, + fileSize: 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, + fileName + }); + 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; + } + } } \ No newline at end of file diff --git a/src/handlers/tool-handlers.ts b/src/handlers/tool-handlers.ts index d1392d7..7c8fea0 100644 --- a/src/handlers/tool-handlers.ts +++ b/src/handlers/tool-handlers.ts @@ -35,6 +35,8 @@ import { DeleteMembershipArgsSchema, GetRolesArgsSchema, GetRoleArgsSchema, + UploadAttachmentArgsSchema, + GetAttachmentsArgsSchema, } from '../tools/index.js'; export class OpenProjectToolHandlers { @@ -172,6 +174,14 @@ export class OpenProjectToolHandlers { 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; + // Utility handlers case 'test_connection': result = await this.handleTestConnection(); @@ -1093,6 +1103,56 @@ export class OpenProjectToolHandlers { }; } + // Upload Attachment handlers + private async handleUploadAttachment(args: any) { + const validatedArgs = UploadAttachmentArgsSchema.parse(args); + const attachment = await this.client.uploadAttachment( + validatedArgs.workPackageId, + validatedArgs.fileName, + validatedArgs.fileContent, + validatedArgs.contentType, + validatedArgs.description + ); + + return { + content: [ + { + type: 'text', + text: `File uploaded successfully:\n\nFile: ${attachment.fileName}\nSize: ${attachment.fileSize} bytes\nType: ${attachment.contentType}\nAttachment ID: ${attachment.id}\nWork Package ID: ${validatedArgs.workPackageId}\nUploaded: ${attachment.createdAt}`, + }, + ], + }; + } + + 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) => + `- ${attachment.fileName} (ID: ${attachment.id})\n Size: ${attachment.fileSize} bytes\n Type: ${attachment.contentType}\n Description: ${attachment.description || 'No description'}\n Uploaded: ${attachment.createdAt}` + ) + .join('\n\n')}`, + }, + ], + }; + } + // Utility handlers private async handleTestConnection() { const isConnected = await this.client.testConnection(); diff --git a/src/tools/index.ts b/src/tools/index.ts index 25440a4..5678f88 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -217,6 +217,20 @@ const GetRoleArgsSchema = z.object({ id: z.number(), }); +// Upload Attachment argument schema +const UploadAttachmentArgsSchema = z.object({ + workPackageId: z.number(), + fileName: z.string(), + fileContent: z.string(), + contentType: z.string().optional(), + description: z.string().optional(), +}); + +// Get Attachments argument schema +const GetAttachmentsArgsSchema = z.object({ + workPackageId: z.number(), +}); + export function createOpenProjectTools(): Tool[] { return [ // Project tools @@ -1045,6 +1059,54 @@ export function createOpenProjectTools(): Tool[] { 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', + }, + fileName: { + type: 'string', + description: 'Name of the file to upload', + }, + fileContent: { + type: 'string', + description: 'Base64 encoded file content', + }, + contentType: { + type: 'string', + description: 'MIME type of the file (e.g., "text/plain", "image/png")', + }, + description: { + type: 'string', + description: 'Optional description for the attachment', + }, + }, + required: ['workPackageId', 'fileName', 'fileContent'], + }, + }, + + // 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'], + }, + }, ]; } @@ -1083,4 +1145,6 @@ export { DeleteMembershipArgsSchema, GetRolesArgsSchema, GetRoleArgsSchema, + UploadAttachmentArgsSchema, + GetAttachmentsArgsSchema, }; \ No newline at end of file diff --git a/src/types/openproject.ts b/src/types/openproject.ts index f16f6c4..d2a82a7 100644 --- a/src/types/openproject.ts +++ b/src/types/openproject.ts @@ -279,6 +279,48 @@ export const RoleSchema = z.object({ }), }); +// Attachment schemas +export const AttachmentSchema = z.object({ + _type: z.literal('Attachment'), + id: z.number(), + fileName: z.string(), + fileSize: z.number(), + contentType: z.string(), + description: z.string().optional(), + createdAt: z.string(), + updatedAt: z.string(), + _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(), + }), + downloadLocation: z.object({ + href: 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'), @@ -362,6 +404,8 @@ export type StatusCollectionResponse = 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; From 47339cee4d6ebcfdf9e3a255816af361da20729b Mon Sep 17 00:00:00 2001 From: Will James Date: Wed, 3 Sep 2025 09:10:52 -0400 Subject: [PATCH 09/15] Refactor upload_attachment tool to accept file path instead of base64 content - Updated UploadAttachmentArgsSchema to use filePath instead of fileName/fileContent/contentType - Modified uploadAttachment method in OpenProjectClient to read files from filesystem - Added automatic content type detection based on file extension - Updated tool handler to use new signature - MCP server now handles all file reading and encoding requirements --- src/client/openproject-client.ts | 70 ++++++++++++++++++++++++++++---- src/handlers/tool-handlers.ts | 6 +-- src/tools/index.ts | 18 ++------ 3 files changed, 68 insertions(+), 26 deletions(-) diff --git a/src/client/openproject-client.ts b/src/client/openproject-client.ts index 6b14fe1..4c8d53c 100644 --- a/src/client/openproject-client.ts +++ b/src/client/openproject-client.ts @@ -1073,27 +1073,79 @@ export class OpenProjectClient { } // Attachment API methods - async uploadAttachment(workPackageId: number, fileName: string, fileContent: string, contentType?: string, description?: string): Promise { + async uploadAttachment(workPackageId: number, filePath: string, description?: string): Promise { const startTime = Date.now(); const url = `/work_packages/${workPackageId}/attachments`; try { - // Decode base64 content - const buffer = Buffer.from(fileContent, 'base64'); + // 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); // Create FormData for multipart upload const formData = new FormData(); - formData.append('file', buffer, { + formData.append('file', fileBuffer, { filename: fileName, - contentType: contentType || 'application/octet-stream' + contentType: contentType }); if (description) { formData.append('description', description); } - logger.logApiRequest('POST', url, { fileName, contentType, description }, { workPackageId }); + logger.logApiRequest('POST', url, { filePath, fileName, contentType, fileSize: stats.size, description }, { workPackageId }); const response = await this.axiosInstance.post(url, formData, { headers: { @@ -1108,9 +1160,11 @@ export class OpenProjectClient { const duration = Date.now() - startTime; logger.info(`uploadAttachment completed successfully (${duration}ms)`, { workPackageId, + filePath, fileName, + fileSize: stats.size, attachmentId: response.data?.id, - fileSize: response.data?.fileSize + uploadedFileSize: response.data?.fileSize }); return AttachmentSchema.parse(response.data); @@ -1121,7 +1175,7 @@ export class OpenProjectClient { status: error.response?.status, responseData: error.response?.data, workPackageId, - fileName + filePath }); throw error; } diff --git a/src/handlers/tool-handlers.ts b/src/handlers/tool-handlers.ts index 7c8fea0..aa6b8b0 100644 --- a/src/handlers/tool-handlers.ts +++ b/src/handlers/tool-handlers.ts @@ -1108,9 +1108,7 @@ export class OpenProjectToolHandlers { const validatedArgs = UploadAttachmentArgsSchema.parse(args); const attachment = await this.client.uploadAttachment( validatedArgs.workPackageId, - validatedArgs.fileName, - validatedArgs.fileContent, - validatedArgs.contentType, + validatedArgs.filePath, validatedArgs.description ); @@ -1118,7 +1116,7 @@ export class OpenProjectToolHandlers { content: [ { type: 'text', - text: `File uploaded successfully:\n\nFile: ${attachment.fileName}\nSize: ${attachment.fileSize} bytes\nType: ${attachment.contentType}\nAttachment ID: ${attachment.id}\nWork Package ID: ${validatedArgs.workPackageId}\nUploaded: ${attachment.createdAt}`, + text: `File uploaded successfully:\n\nFile: ${attachment.fileName}\nPath: ${validatedArgs.filePath}\nSize: ${attachment.fileSize} bytes\nType: ${attachment.contentType}\nAttachment ID: ${attachment.id}\nWork Package ID: ${validatedArgs.workPackageId}\nUploaded: ${attachment.createdAt}`, }, ], }; diff --git a/src/tools/index.ts b/src/tools/index.ts index 5678f88..cd986df 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -220,9 +220,7 @@ const GetRoleArgsSchema = z.object({ // Upload Attachment argument schema const UploadAttachmentArgsSchema = z.object({ workPackageId: z.number(), - fileName: z.string(), - fileContent: z.string(), - contentType: z.string().optional(), + filePath: z.string(), description: z.string().optional(), }); @@ -1071,24 +1069,16 @@ export function createOpenProjectTools(): Tool[] { type: 'number', description: 'Work package ID to attach the file to', }, - fileName: { + filePath: { type: 'string', - description: 'Name of the file to upload', - }, - fileContent: { - type: 'string', - description: 'Base64 encoded file content', - }, - contentType: { - type: 'string', - description: 'MIME type of the file (e.g., "text/plain", "image/png")', + description: 'Path to the file to upload (absolute or relative to working directory)', }, description: { type: 'string', description: 'Optional description for the attachment', }, }, - required: ['workPackageId', 'fileName', 'fileContent'], + required: ['workPackageId', 'filePath'], }, }, From 0d74d003639c9bf71d35fda9864bfea1a9da3d92 Mon Sep 17 00:00:00 2001 From: Will James Date: Wed, 3 Sep 2025 10:01:20 -0400 Subject: [PATCH 10/15] Fix sortBy parameter formatting for OpenProject API - Add formatSortByParameter method to handle different API endpoint formats - Projects API: sort=field:direction format - Work Packages API: sortBy=[[field,direction]] format - Support multiple sort criteria and direction normalization - Fix API errors when using sortBy parameter - Tested with projects and work packages sorting Resolves ticket 121 - OpenProject Projects SortBy bug --- src/client/openproject-client.ts | 81 ++++++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 14 deletions(-) diff --git a/src/client/openproject-client.ts b/src/client/openproject-client.ts index 6b14fe1..4e136d0 100644 --- a/src/client/openproject-client.ts +++ b/src/client/openproject-client.ts @@ -79,9 +79,56 @@ export class OpenProjectClient { ); } - private buildQueryString(params: QueryParams): string { + /** + * Formats sortBy parameter from user input to OpenProject API format + * Input: "name:asc" or "name:asc,id:desc" + * Output formats: + * Projects API: [{"name":"asc"}] or [{"name":"asc"},{"id":"desc"}] + * Work Packages API: [["name","asc"]] or [["name","asc"],["id","desc"]] + */ + private formatSortByParameter(sortBy: string, endpoint: 'projects' | 'work_packages' | 'other' = 'other'): string { + try { + const sortCriteria = sortBy.split(',').map(criterion => criterion.trim()); + const formattedCriteria = sortCriteria.map(criterion => { + const [field, direction] = criterion.split(':').map(s => s.trim()); + + if (!field || !direction) { + throw new Error(`Invalid sort criterion format: ${criterion}. Expected format: field:direction`); + } + + // Normalize direction + let normalizedDirection: string; + switch (direction.toLowerCase()) { + case 'asc': + case 'ascending': + normalizedDirection = 'asc'; + break; + case 'desc': + case 'descending': + normalizedDirection = 'desc'; + break; + default: + throw new Error(`Invalid sort direction: ${direction}. Must be asc/desc/ascending/descending`); + } + + // Return different format based on endpoint + if (endpoint === 'work_packages') { + return [field, normalizedDirection]; + } else { + return { [field]: normalizedDirection }; + } + }); + + return JSON.stringify(formattedCriteria); + } catch (error) { + logger.error('Error formatting sortBy parameter:', error); + throw new Error(`Invalid sortBy format: ${sortBy}. Expected format: "field:direction" or "field:direction,field2:direction2"`); + } + } + + private buildQueryString(params: QueryParams, endpoint: 'projects' | 'work_packages' | 'other' = 'other'): string { const searchParams = new URLSearchParams(); - + if (params.offset !== undefined) { searchParams.append('offset', params.offset.toString()); } @@ -92,7 +139,14 @@ export class OpenProjectClient { searchParams.append('filters', params.filters); } if (params.sortBy) { - searchParams.append('sortBy', params.sortBy); + const formattedSortBy = this.formatSortByParameter(params.sortBy, endpoint); + + // Use different parameter names based on endpoint + if (endpoint === 'work_packages') { + searchParams.append('sortBy', formattedSortBy); + } else { + searchParams.append('sort', formattedSortBy); + } } if (params.groupBy) { searchParams.append('groupBy', params.groupBy); @@ -108,7 +162,7 @@ export class OpenProjectClient { // Projects API async getProjects(params: QueryParams = {}): Promise { const startTime = Date.now(); - const queryString = this.buildQueryString(params); + const queryString = this.buildQueryString(params, 'projects'); const url = `/projects${queryString}`; try { @@ -184,7 +238,7 @@ export class OpenProjectClient { // Work Packages API async getWorkPackages(params: QueryParams = {}): Promise { const startTime = Date.now(); - const queryString = this.buildQueryString(params); + const queryString = this.buildQueryString(params, 'work_packages'); const url = `/work_packages${queryString}`; try { @@ -539,7 +593,7 @@ export class OpenProjectClient { // Users API async getUsers(params: QueryParams = {}): Promise { - const queryString = this.buildQueryString(params); + const queryString = this.buildQueryString(params, 'other'); const response = await this.axiosInstance.get(`/users${queryString}`); return CollectionResponseSchema.parse(response.data); } @@ -570,14 +624,14 @@ export class OpenProjectClient { // Statuses API async getStatuses(params: QueryParams = {}): Promise { - const queryString = this.buildQueryString(params); + const queryString = this.buildQueryString(params, 'other'); 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); + const queryString = this.buildQueryString(params, 'other'); const response = await this.axiosInstance.get(`/time_entries${queryString}`); return CollectionResponseSchema.parse(response.data); } @@ -604,7 +658,7 @@ export class OpenProjectClient { // Membership API methods async getMemberships(params: QueryParams = {}): Promise { const startTime = Date.now(); - const queryString = this.buildQueryString(params); + const queryString = this.buildQueryString(params, 'other'); const url = `/memberships${queryString}`; try { @@ -766,7 +820,7 @@ export class OpenProjectClient { // Role API methods async getRoles(params: QueryParams = {}): Promise { const startTime = Date.now(); - const queryString = this.buildQueryString(params); + const queryString = this.buildQueryString(params, 'other'); const url = `/roles${queryString}`; try { @@ -889,7 +943,7 @@ export class OpenProjectClient { // Grid/Board API methods for Kanban functionality async getGrids(params: QueryParams = {}): Promise { - const queryString = this.buildQueryString(params); + const queryString = this.buildQueryString(params, 'other'); const response = await this.axiosInstance.get(`/grids${queryString}`); return CollectionResponseSchema.parse(response.data); } @@ -1084,7 +1138,7 @@ export class OpenProjectClient { // Create FormData for multipart upload const formData = new FormData(); - formData.append('file', buffer, { + formData.append('attachment', buffer, { filename: fileName, contentType: contentType || 'application/octet-stream' }); @@ -1097,8 +1151,7 @@ export class OpenProjectClient { const response = await this.axiosInstance.post(url, formData, { headers: { - ...formData.getHeaders(), - 'Content-Type': 'multipart/form-data' + ...formData.getHeaders() } }); From 0abec22526e7c2cd69be7e098640ffd485e194c1 Mon Sep 17 00:00:00 2001 From: Will James Date: Wed, 3 Sep 2025 10:44:30 -0400 Subject: [PATCH 11/15] feat: add upload attachment tools for OpenProject MCP - Add upload_attachment tool to upload files to work packages - Add get_attachments tool to retrieve work package attachments - Implement manual multipart form data construction for API compliance - Add comprehensive MIME type detection for 25+ file formats - Update attachment schemas to match OpenProject API response format - Add proper error handling and logging for file operations - Fix attachment description handling for string/object formats Closes ticket #120: Feat - add upload tool to OpenProject MCP --- src/client/openproject-client.ts | 45 ++++++++++++++++++++------------ src/handlers/tool-handlers.ts | 18 ++++++++++--- src/types/openproject.ts | 19 +++++++++++--- 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/src/client/openproject-client.ts b/src/client/openproject-client.ts index 4c8d53c..2f38623 100644 --- a/src/client/openproject-client.ts +++ b/src/client/openproject-client.ts @@ -1,6 +1,5 @@ import axios, { AxiosInstance } from 'axios'; import https from 'https'; -import FormData from 'form-data'; import { OpenProjectConfig, Project, @@ -1133,24 +1132,36 @@ export class OpenProjectClient { // Read file content const fileBuffer = fs.readFileSync(filePath); - // Create FormData for multipart upload - const formData = new FormData(); - - formData.append('file', fileBuffer, { - filename: fileName, - contentType: contentType - }); - - if (description) { - formData.append('description', description); - } - + // 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 }); - - const response = await this.axiosInstance.post(url, formData, { + logger.debug('Manual multipart boundary', boundary); + logger.debug('File buffer length', fileBuffer.length); + + const response = await this.axiosInstance.post(url, body, { headers: { - ...formData.getHeaders(), - 'Content-Type': 'multipart/form-data' + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': body.length.toString() } }); diff --git a/src/handlers/tool-handlers.ts b/src/handlers/tool-handlers.ts index aa6b8b0..ad54d2e 100644 --- a/src/handlers/tool-handlers.ts +++ b/src/handlers/tool-handlers.ts @@ -1116,7 +1116,7 @@ export class OpenProjectToolHandlers { content: [ { type: 'text', - text: `File uploaded successfully:\n\nFile: ${attachment.fileName}\nPath: ${validatedArgs.filePath}\nSize: ${attachment.fileSize} bytes\nType: ${attachment.contentType}\nAttachment ID: ${attachment.id}\nWork Package ID: ${validatedArgs.workPackageId}\nUploaded: ${attachment.createdAt}`, + text: `File uploaded successfully:\n\nFile: ${attachment.fileName}\nPath: ${validatedArgs.filePath}\nSize: ${attachment.fileSize} bytes\nAttachment ID: ${attachment.id}\nWork Package ID: ${validatedArgs.workPackageId}`, }, ], }; @@ -1142,9 +1142,19 @@ export class OpenProjectToolHandlers { { type: 'text', text: `Found ${result.total} attachments for work package ID ${validatedArgs.workPackageId}:\n\n${result._embedded.elements - .map((attachment: any) => - `- ${attachment.fileName} (ID: ${attachment.id})\n Size: ${attachment.fileSize} bytes\n Type: ${attachment.contentType}\n Description: ${attachment.description || 'No description'}\n Uploaded: ${attachment.createdAt}` - ) + .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')}`, }, ], diff --git a/src/types/openproject.ts b/src/types/openproject.ts index d2a82a7..b0a5d87 100644 --- a/src/types/openproject.ts +++ b/src/types/openproject.ts @@ -285,10 +285,14 @@ export const AttachmentSchema = z.object({ id: z.number(), fileName: z.string(), fileSize: z.number(), - contentType: z.string(), - description: z.string().optional(), - createdAt: z.string(), - updatedAt: z.string(), + 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(), @@ -302,9 +306,16 @@ export const AttachmentSchema = 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(), + }), }), }); From 4bc1038d3e96ada2ea331d41ad6d15e3dd3c9fe0 Mon Sep 17 00:00:00 2001 From: Will James Date: Wed, 3 Sep 2025 15:47:45 -0400 Subject: [PATCH 12/15] feat: Add create_task tool to OpenProject MCP - Add CreateTaskArgsSchema for task creation parameters - Add create_task tool definition with simplified interface - Implement handleCreateTask method that automatically sets typeId to 1 (Task) - Register the new tool and handler in the MCP server - Update exports to include CreateTaskArgsSchema The create_task tool provides a convenient wrapper around create_work_package that automatically sets the work package type to Task (ID: 1), eliminating the need for users to remember type IDs and avoiding parameter validation issues with other type IDs. Task ID: 140 - Feat - Add create_task tool to OpenProject MCP --- src/handlers/tool-handlers.ts | 42 +++++++++++++++++++++++-- src/tools/index.ts | 59 +++++++++++++++++++++++++++++++++++ test.env | 5 +++ 3 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 test.env diff --git a/src/handlers/tool-handlers.ts b/src/handlers/tool-handlers.ts index ad54d2e..d22c241 100644 --- a/src/handlers/tool-handlers.ts +++ b/src/handlers/tool-handlers.ts @@ -10,6 +10,7 @@ import { GetWorkPackagesArgsSchema, GetWorkPackageArgsSchema, CreateWorkPackageArgsSchema, + CreateTaskArgsSchema, UpdateWorkPackageArgsSchema, SetWorkPackageStatusArgsSchema, DeleteWorkPackageArgsSchema, @@ -79,6 +80,9 @@ 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; @@ -456,7 +460,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, @@ -472,9 +476,9 @@ export class OpenProjectToolHandlers { ...(validatedArgs.assigneeId && { assignee: { href: `/api/v3/users/${validatedArgs.assigneeId}` } }), }, }; - + const workPackage = await this.client.createWorkPackage(workPackageData); - + return { content: [ { @@ -485,6 +489,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; diff --git a/src/tools/index.ts b/src/tools/index.ts index cd986df..36caaa9 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(), @@ -436,6 +448,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', @@ -1110,6 +1168,7 @@ export { GetWorkPackagesArgsSchema, GetWorkPackageArgsSchema, CreateWorkPackageArgsSchema, + CreateTaskArgsSchema, UpdateWorkPackageArgsSchema, SetWorkPackageStatusArgsSchema, DeleteWorkPackageArgsSchema, 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 From 3aea5e4a5844b297bba1b60d0209996cf21ec932 Mon Sep 17 00:00:00 2001 From: Will James Date: Thu, 4 Sep 2025 12:11:17 -0400 Subject: [PATCH 13/15] remove unwanted coding file --- .cursorrules | 87 ---------------------------------------------------- 1 file changed, 87 deletions(-) delete mode 100644 .cursorrules diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index d8f175f..0000000 --- a/.cursorrules +++ /dev/null @@ -1,87 +0,0 @@ -# OpenProject MCP Server Development Rules - -## Core Development Rules - -### 1. OpenProject API Testing Requirements -- **ALWAYS test OpenProject HTTP APIs directly using curl/HTTP clients** to confirm proper API functionality when developing a new feature -- **DO NOT test through MCP tools** - MCP tools are just wrappers around the APIs -- Use authentication from `.env` file for direct API testing -- Verify endpoints, parameters, and responses work as expected -- Test both success and error scenarios -- Document any API limitations or unexpected behaviors -- Use OpenProject API learnings to develop MCP tools that interface to those APIs - -**Example of Direct API Testing:** -```bash -# Test OpenProject statuses endpoint directly with curl -curl -u "apikey:YOUR_API_KEY" "https://your-instance.openproject.com/api/v3/statuses" - -# Test work package update directly -curl -X PATCH -u "apikey:YOUR_API_KEY" \ - -H "Content-Type: application/json" \ - "https://your-instance.openproject.com/api/v3/work_packages/2" \ - -d '{"lockVersion": 1, "_links": {"status": {"href": "/api/v3/statuses/1"}}}' -``` - -**What NOT to do:** -- Don't test by calling MCP tools like `mcp_openproject_get_statuses` -- MCP tools are the implementation, not the testing method - -### 2. MCP Server Management -- Ensure server is restarted and running before testing new functionality -- Use appropriate npm scripts for development and production -- Follow proper procedures for relaunching the MCP server after code changes - 1. pm2 stop mcp-openproject - 2. pm2 delete mcp-openproject - 3. npm run build - 4. pm2 start dist/index.js --name mcp-openproject - 5. stop and ask user to restart the MCP server in Cursor - -### 3. Blocked Work Protocol -- If stuck on a technical issue, **immediately update the ticket** with: - - Clear description of the sticking point - - What has been tried - - Current error messages or issues - - Next steps needed -- **Assign the ticket to Will James** for assistance -- Do not spend excessive time trying to resolve blocking issues alone -- Do not write to file journals - -### 4. Cleanup and Deletion Protocol -- If something needs to be deleted (files, code, etc.): - - **Note it in the ticket** for deletion later - - **Do NOT get stuck waiting for delete approval** - - Continue with implementation and mark deletion as a separate task - - Use TODO comments in code to mark items for cleanup - -## Development Workflow - -### Development Order (MUST FOLLOW): -1. **FIRST**: Test OpenProject HTTP APIs directly with curl/HTTP clients -2. **SECOND**: Implement MCP tools based on working API understanding -3. **THIRD**: Test MCP server functionality -4. **FOURTH**: Update ticket and mark complete - -### Before Marking Work Complete: -1. ✅ Code compiles without errors -2. ✅ OpenProject HTTP APIs tested directly with curl/HTTP clients and working -3. ✅ MCP server functionality verified -4. ✅ Ticket updated with implementation details -5. ✅ Any cleanup items noted for future action - -### When Blocked: -1. 🔴 Stop current work immediately -2. 🔴 Update ticket with blocking issue details -3. 🔴 Assign to Will James -4. 🔴 Move to next available task - -### Code Quality Standards: -- Use TypeScript strict mode -- Implement proper error handling -- Add comprehensive logging -- Follow existing code patterns - -## File Management -- Keep `.cursorrules` in version control for team consistency -- Update rules as project evolves -- Document any new patterns or requirements From 3d12886377321eba440d5a7b4f7c94fe9e59ef5a Mon Sep 17 00:00:00 2001 From: Will James Date: Thu, 4 Sep 2025 12:29:39 -0400 Subject: [PATCH 14/15] add ignore local coding agent rules --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From 2f81561cd25ccab0b0314c39de1fb4413d82b255 Mon Sep 17 00:00:00 2001 From: Will James Date: Thu, 4 Sep 2025 13:24:09 -0400 Subject: [PATCH 15/15] feat(attachments): add download_attachment tool and fix relative href handling; implement handler; tests via MCP complete (ticket 139) --- src/client/openproject-client.ts | 87 ++++++++++++++++++++++++++++++++ src/handlers/tool-handlers.ts | 21 ++++++++ src/tools/index.ts | 27 ++++++++++ 3 files changed, 135 insertions(+) diff --git a/src/client/openproject-client.ts b/src/client/openproject-client.ts index 2f38623..54f2aa4 100644 --- a/src/client/openproject-client.ts +++ b/src/client/openproject-client.ts @@ -1222,4 +1222,91 @@ export class OpenProjectClient { 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 d22c241..111d9d6 100644 --- a/src/handlers/tool-handlers.ts +++ b/src/handlers/tool-handlers.ts @@ -38,6 +38,7 @@ import { GetRoleArgsSchema, UploadAttachmentArgsSchema, GetAttachmentsArgsSchema, + DownloadAttachmentArgsSchema, } from '../tools/index.js'; export class OpenProjectToolHandlers { @@ -185,6 +186,9 @@ export class OpenProjectToolHandlers { case 'get_attachments': result = await this.handleGetAttachments(args); break; + case 'download_attachment': + result = await this.handleDownloadAttachment(args); + break; // Utility handlers case 'test_connection': @@ -1197,6 +1201,23 @@ export class OpenProjectToolHandlers { }; } + 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(); diff --git a/src/tools/index.ts b/src/tools/index.ts index 36caaa9..3c51653 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -241,6 +241,12 @@ 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 @@ -1155,6 +1161,26 @@ export function createOpenProjectTools(): Tool[] { 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'], + }, + }, ]; } @@ -1196,4 +1222,5 @@ export { GetRoleArgsSchema, UploadAttachmentArgsSchema, GetAttachmentsArgsSchema, + DownloadAttachmentArgsSchema, }; \ No newline at end of file