Skip to main content
Glama
liratanak

Tonle OpenProject MCP Server

by liratanak
openproject-client.ts16.1 kB
/** * OpenProject API Client * A typed HTTP client for OpenProject API v3 */ import logger from './logger.ts'; export interface OpenProjectConfig { baseUrl: string; apiKey: string; timeout?: number; caller?: string; } export interface HALResponse<T = unknown> { _type: string; _embedded?: Record<string, unknown>; _links: Record<string, { href: string; title?: string }>; total?: number; count?: number; pageSize?: number; offset?: number; elements?: T[]; } export interface Project { id: number; identifier: string; name: string; description?: { format: string; raw: string; html: string }; public: boolean; active: boolean; statusExplanation?: { format: string; raw: string; html: string }; createdAt: string; updatedAt: string; _links: Record<string, { href: string; title?: string }>; } export interface WorkPackage { id: number; subject: string; description?: { format: string; raw: string; html: string }; scheduleManually: boolean; startDate?: string; dueDate?: string; derivedStartDate?: string; derivedDueDate?: string; estimatedTime?: string; derivedEstimatedTime?: string; spentTime?: string; percentageDone: number; createdAt: string; updatedAt: string; _links: Record<string, { href: string; title?: string }>; } export interface User { id: number; login: string; firstName: string; lastName: string; name: string; email?: string; admin: boolean; status: string; createdAt: string; updatedAt: string; _links: Record<string, { href: string; title?: string }>; } export interface Type { id: number; name: string; color: string; position: number; isDefault: boolean; isMilestone: boolean; createdAt: string; updatedAt: string; _links: Record<string, { href: string; title?: string }>; } export interface Status { id: number; name: string; color: string; position: number; isDefault: boolean; isClosed: boolean; isReadonly: boolean; _links: Record<string, { href: string; title?: string }>; } export interface Priority { id: number; name: string; color: string; position: number; isDefault: boolean; isActive: boolean; _links: Record<string, { href: string; title?: string }>; } export interface MembershipRole { id: number; name: string; _links: Record<string, { href: string; title?: string }>; } export interface MembershipPrincipal { id: number; name: string; _type: string; [key: string]: unknown; } export interface Membership { id: number; _type: 'Membership'; createdAt: string; updatedAt: string; _embedded?: { project?: Project; principal?: MembershipPrincipal; roles?: MembershipRole[]; }; _links: Record<string, { href: string; title?: string }>; } export interface TimeEntry { id: number; comment?: { format: string; raw: string; html: string }; spentOn: string; hours: string; createdAt: string; updatedAt: string; _links: Record<string, { href: string; title?: string }>; } export interface Version { id: number; name: string; description?: { format: string; raw: string; html: string }; startDate?: string; endDate?: string; status: string; sharing: string; createdAt: string; updatedAt: string; _links: Record<string, { href: string; title?: string }>; } export interface Activity { id: number; comment?: { format: string; raw: string; html: string }; version: number; createdAt: string; _links: Record<string, { href: string; title?: string }>; } export interface OpenProjectError { _type: 'Error'; errorIdentifier: string; message: string; _embedded?: { details?: { attribute?: string; }; }; } export class OpenProjectClient { private config: OpenProjectConfig; private headers: Record<string, string>; private caller: string; constructor(config: OpenProjectConfig) { this.config = { ...config, baseUrl: config.baseUrl.replace(/\/$/, ''), // Remove trailing slash timeout: config.timeout ?? 30000, }; this.caller = config.caller || 'unknown'; // Basic Auth with API key as username and 'x' as password const credentials = Buffer.from(`apikey:${config.apiKey}`).toString('base64'); this.headers = { 'Authorization': `Basic ${credentials}`, 'Content-Type': 'application/json', 'Accept': 'application/hal+json', }; } setCaller(caller: string): void { this.caller = caller; } private async request<T>( method: string, endpoint: string, body?: unknown, params?: Record<string, string | number | boolean | undefined> ): Promise<T> { const url = new URL(`${this.config.baseUrl}/api/v3${endpoint}`); if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined) { url.searchParams.append(key, String(value)); } }); } // Log the API request logger.logApiRequest(this.caller, method, endpoint, params, body); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); try { const response = await fetch(url.toString(), { method, headers: this.headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); clearTimeout(timeoutId); const data = await response.json(); if (!response.ok) { const error = data as OpenProjectError; const errorMessage = `OpenProject API Error: ${error.message || response.statusText} (${error.errorIdentifier || response.status})`; // Log the API error logger.logApiError(this.caller, method, endpoint, new Error(errorMessage)); throw new Error(errorMessage); } // Log the successful API response logger.logApiResponse(this.caller, method, endpoint, response.status, data); return data as T; } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === 'AbortError') { const timeoutError = new Error(`Request timeout after ${this.config.timeout}ms`); logger.logApiError(this.caller, method, endpoint, timeoutError); throw timeoutError; } // Log any other errors if (error instanceof Error) { logger.logApiError(this.caller, method, endpoint, error); } throw error; } } // ============== Root & Configuration ============== async getRoot(): Promise<HALResponse> { return this.request('GET', ''); } async getConfiguration(): Promise<HALResponse> { return this.request('GET', '/configuration'); } // ============== Projects ============== async listProjects(params?: { offset?: number; pageSize?: number; filters?: string; sortBy?: string; }): Promise<HALResponse<Project>> { return this.request('GET', '/projects', undefined, params); } async getProject(id: number | string): Promise<Project> { return this.request('GET', `/projects/${id}`); } async createProject(data: { name: string; identifier?: string; description?: { raw: string }; public?: boolean; status?: string; statusExplanation?: { raw: string }; parent?: { href: string }; }): Promise<Project> { return this.request('POST', '/projects', data); } async updateProject( id: number | string, data: { name?: string; description?: { raw: string }; public?: boolean; active?: boolean; status?: string; statusExplanation?: { raw: string }; } ): Promise<Project> { return this.request('PATCH', `/projects/${id}`, data); } async deleteProject(id: number | string): Promise<void> { await this.request('DELETE', `/projects/${id}`); } // ============== Work Packages ============== async listWorkPackages(params?: { offset?: number; pageSize?: number; filters?: string; sortBy?: string; groupBy?: string; showSums?: boolean; query_id?: number; }): Promise<HALResponse<WorkPackage>> { return this.request('GET', '/work_packages', undefined, params); } async listProjectWorkPackages( projectId: number | string, params?: { offset?: number; pageSize?: number; filters?: string; sortBy?: string; query_id?: number; } ): Promise<HALResponse<WorkPackage>> { return this.request('GET', `/projects/${projectId}/work_packages`, undefined, params); } async getWorkPackage(id: number): Promise<WorkPackage> { return this.request('GET', `/work_packages/${id}`); } async createWorkPackage( projectId: number | string, data: { subject: string; description?: { raw: string }; _links?: { type?: { href: string }; status?: { href: string }; priority?: { href: string }; assignee?: { href: string }; responsible?: { href: string }; version?: { href: string }; parent?: { href: string }; }; startDate?: string; dueDate?: string; estimatedTime?: string; percentageDone?: number; }, notify?: boolean ): Promise<WorkPackage> { return this.request('POST', `/projects/${projectId}/work_packages`, data, { notify }); } async updateWorkPackage( id: number, data: { lockVersion: number; subject?: string; description?: { raw: string }; _links?: { type?: { href: string }; status?: { href: string }; priority?: { href: string }; assignee?: { href: string }; responsible?: { href: string }; version?: { href: string }; parent?: { href: string }; }; startDate?: string; dueDate?: string; estimatedTime?: string; percentageDone?: number; }, notify?: boolean ): Promise<WorkPackage> { return this.request('PATCH', `/work_packages/${id}`, data, { notify }); } async deleteWorkPackage(id: number): Promise<void> { await this.request('DELETE', `/work_packages/${id}`); } async listWorkPackageActivities(id: number): Promise<HALResponse<Activity>> { return this.request('GET', `/work_packages/${id}/activities`); } // ============== Users ============== async listUsers(params?: { offset?: number; pageSize?: number; filters?: string; sortBy?: string; }): Promise<HALResponse<User>> { return this.request('GET', '/users', undefined, params); } async getUser(id: number | string): Promise<User> { return this.request('GET', `/users/${id}`); } async getCurrentUser(): Promise<User> { return this.request('GET', '/users/me'); } async createUser(data: { login: string; email: string; firstName: string; lastName: string; admin?: boolean; language?: string; password?: string; }): Promise<User> { return this.request('POST', '/users', data); } async updateUser( id: number, data: { login?: string; email?: string; firstName?: string; lastName?: string; admin?: boolean; language?: string; } ): Promise<User> { return this.request('PATCH', `/users/${id}`, data); } async deleteUser(id: number): Promise<void> { await this.request('DELETE', `/users/${id}`); } async lockUser(id: number): Promise<User> { return this.request('POST', `/users/${id}/lock`); } async unlockUser(id: number): Promise<User> { return this.request('DELETE', `/users/${id}/lock`); } // ============== Types ============== async listTypes(): Promise<HALResponse<Type>> { return this.request('GET', '/types'); } async getType(id: number): Promise<Type> { return this.request('GET', `/types/${id}`); } async listProjectTypes(projectId: number | string): Promise<HALResponse<Type>> { return this.request('GET', `/projects/${projectId}/types`); } // ============== Statuses ============== async listStatuses(): Promise<HALResponse<Status>> { return this.request('GET', '/statuses'); } async getStatus(id: number): Promise<Status> { return this.request('GET', `/statuses/${id}`); } // ============== Priorities ============== async listPriorities(): Promise<HALResponse<Priority>> { return this.request('GET', '/priorities'); } async getPriority(id: number): Promise<Priority> { return this.request('GET', `/priorities/${id}`); } // ============== Time Entries ============== async listTimeEntries(params?: { offset?: number; pageSize?: number; filters?: string; sortBy?: string; }): Promise<HALResponse<TimeEntry>> { return this.request('GET', '/time_entries', undefined, params); } async getTimeEntry(id: number): Promise<TimeEntry> { return this.request('GET', `/time_entries/${id}`); } async createTimeEntry(data: { _links: { project: { href: string }; workPackage?: { href: string }; activity: { href: string }; }; hours: string; spentOn: string; comment?: { raw: string }; }): Promise<TimeEntry> { return this.request('POST', '/time_entries', data); } async updateTimeEntry( id: number, data: { _links?: { activity?: { href: string }; }; hours?: string; spentOn?: string; comment?: { raw: string }; } ): Promise<TimeEntry> { return this.request('PATCH', `/time_entries/${id}`, data); } async deleteTimeEntry(id: number): Promise<void> { await this.request('DELETE', `/time_entries/${id}`); } // ============== Versions ============== async listVersions(params?: { offset?: number; pageSize?: number; filters?: string; }): Promise<HALResponse<Version>> { return this.request('GET', '/versions', undefined, params); } async getVersion(id: number): Promise<Version> { return this.request('GET', `/versions/${id}`); } async listProjectVersions(projectId: number | string): Promise<HALResponse<Version>> { return this.request('GET', `/projects/${projectId}/versions`); } async createVersion(data: { name: string; _links: { definingProject: { href: string }; }; description?: { raw: string }; startDate?: string; endDate?: string; status?: string; sharing?: string; }): Promise<Version> { return this.request('POST', '/versions', data); } async updateVersion( id: number, data: { name?: string; description?: { raw: string }; startDate?: string; endDate?: string; status?: string; sharing?: string; } ): Promise<Version> { return this.request('PATCH', `/versions/${id}`, data); } async deleteVersion(id: number): Promise<void> { await this.request('DELETE', `/versions/${id}`); } // ============== Activities (Journal) ============== async getActivity(id: number): Promise<Activity> { return this.request('GET', `/activities/${id}`); } // ============== Principals ============== async listPrincipals(params?: { offset?: number; pageSize?: number; filters?: string; }): Promise<HALResponse> { return this.request('GET', '/principals', undefined, params); } // ============== Memberships ============== async listMemberships(params?: { offset?: number; pageSize?: number; filters?: string; sortBy?: string; }): Promise<HALResponse<Membership>> { return this.request('GET', '/memberships', undefined, params); } } export function createClient(caller?: string): OpenProjectClient { const baseUrl = process.env.OPENPROJECT_URL; const apiKey = process.env.OPENPROJECT_API_KEY || process.env.OPENPROJECT_TOKEN; if (!baseUrl) { throw new Error('OPENPROJECT_URL environment variable is required'); } if (!apiKey) { throw new Error('OPENPROJECT_API_KEY or OPENPROJECT_TOKEN environment variable is required'); } return new OpenProjectClient({ baseUrl, apiKey, timeout: process.env.OPENPROJECT_TIMEOUT ? parseInt(process.env.OPENPROJECT_TIMEOUT) : 30000, caller: caller || 'system', }); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/liratanak/openproject-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server