Skip to main content
Glama

MCP-openproject

index.ts21.8 kB
import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { CallToolResult, GetPromptResult, ReadResourceResult, JSONRPCError, } from "@modelcontextprotocol/sdk/types.js"; import axios from 'axios'; // Added for making HTTP requests // OpenProject Configuration - Read from environment variables const OPENPROJECT_API_KEY = process.env.OPENPROJECT_API_KEY; const OPENPROJECT_URL = process.env.OPENPROJECT_URL; const OPENPROJECT_API_VERSION = process.env.OPENPROJECT_API_VERSION || "v3"; // Default to v3 if not set // Check if essential variables are defined if (!OPENPROJECT_API_KEY || !OPENPROJECT_URL) { console.error("FATAL: Missing OpenProject environment variables (OPENPROJECT_API_KEY, OPENPROJECT_URL)"); // Optionally, you could throw an error here to prevent the server from starting incorrectly // For a serverless function, this might mean it fails on invocation if not caught earlier. } let openProjectApi: any; // Define it here try { console.log("[MCP Server Index] Attempting to create openProjectApi client..."); console.log(`[MCP Server Index] Env OPENPROJECT_API_KEY type: ${typeof OPENPROJECT_API_KEY}, value (partial): ${(OPENPROJECT_API_KEY || 'MISSING').substring(0,5)}...`); console.log(`[MCP Server Index] Env OPENPROJECT_URL: ${OPENPROJECT_URL || 'MISSING'}`); console.log(`[MCP Server Index] Env OPENPROJECT_API_VERSION: ${OPENPROJECT_API_VERSION || 'MISSING'}`); openProjectApi = axios.create({ baseURL: `${OPENPROJECT_URL}/api/${OPENPROJECT_API_VERSION}`, headers: { 'Authorization': `Basic ${Buffer.from(`apikey:${OPENPROJECT_API_KEY || ""}`).toString('base64')}`, 'Content-Type': 'application/json' } }); console.log("[MCP Server Index] openProjectApi client created successfully."); } catch (e: any) { console.error("[MCP Server Index] FATAL ERROR creating openProjectApi client:", e.message, e.stack); // If this fails, the server is unusable for OpenProject tools. // We might want to ensure openProjectApi is defined, or subsequent calls will fail. openProjectApi = null; // or some other way to indicate failure } export const setupMCPServer = (): McpServer => { if (!openProjectApi) { console.error("[MCP Server Index - setupMCPServer] openProjectApi client was not initialized. OpenProject tools will fail."); // Potentially throw an error or return a server instance that indicates this critical failure. } const server = new McpServer( { name: "stateless-server", version: "1.0.0", }, { capabilities: { logging: {} } } ); // Register a prompt template that allows the server to // provide the context structure and (optionally) the variables // that should be placed inside of the prompt for client to fill in. server.prompt( "greeting-template", "A simple greeting prompt template", { name: z.string().describe("Name to include in greeting"), }, async ({ name }): Promise<GetPromptResult> => { return { messages: [ { role: "user", content: { type: "text", text: `Please greet ${name} in a friendly manner.`, }, }, ], }; } ); // Register a tool specifically for testing the ability // to resume notification streams to the client server.tool( "start-notification-stream", "Starts sending periodic notifications for testing resumability", { interval: z .number() .describe("Interval in milliseconds between notifications") .default(100), count: z .number() .describe("Number of notifications to send (0 for 100)") .default(10), }, async ( { interval, count }, { sendNotification } ): Promise<CallToolResult> => { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); let counter = 0; while (count === 0 || counter < count) { counter++; try { await sendNotification({ method: "notifications/message", params: { level: "info", data: `Periodic notification #${counter} at ${new Date().toISOString()}`, }, }); } catch (error) { console.error("Error sending notification:", error); } // Wait for the specified interval await sleep(interval); } return { content: [ { type: "text", text: `Started sending periodic notifications every ${interval}ms`, }, ], }; } ); // Create a resource that can be fetched by the client through // this MCP server. server.resource( "greeting-resource", "https://example.com/greetings/default", { mimeType: "text/plain" }, async (): Promise<ReadResourceResult> => { return { contents: [ { uri: "https://example.com/greetings/default", text: "Hello, world!", }, ], }; } ); // --- OpenProject Tools --- server.tool( "openproject-create-project", "Creates a new project in OpenProject", { name: z.string().describe("Name of the project"), identifier: z.string().describe("Identifier of the project (unique)"), description: z.string().optional().describe("Optional description for the project"), }, async ({ name, identifier, description }): Promise<CallToolResult> => { try { const response = await openProjectApi.post('/projects', { name, identifier, description: description || "", // Ensure description is a string }); return { content: [ { type: "text", text: `Successfully created project: ${response.data.name} (ID: ${response.data.id})`, }, { type: "text", text: JSON.stringify(response.data), } ], }; } catch (error: any) { console.error("Error creating OpenProject project:", error.response?.data || error.message); // It's good practice to return a structured error that the MCP client can understand const errorData = error.response?.data || { message: error.message }; return { // Error content should ideally be in a format the client expects for errors // For now, returning it as text. Consider a more structured error object. content: [ { type: "text", text: `Error creating project: ${JSON.stringify(errorData)}`, }, ], // You might also want to throw a JSONRPCError if the SDK/transport supports it well // This requires the error to be caught and transformed by the transport layer // or handled in a way that it becomes a JSONRPCError response. // For simplicity, we're returning an error message in the content. }; } } ); server.tool( "openproject-create-task", "Creates a new task (work package) in an OpenProject project", { projectId: z.string().describe("The ID or identifier of the project to add the task to"), subject: z.string().describe("Subject/title of the task"), description: z.string().optional().describe("Optional description for the task"), type: z.string().default("/api/v3/types/1").describe("Type of the work package (e.g., /api/v3/types/1 for Task)"), // Add other relevant fields like assignee, status, priority as needed }, async ({ projectId, subject, description, type }): Promise<CallToolResult> => { try { // First, ensure the project exists or get its numerical ID if an identifier is passed // For simplicity, this example assumes projectId is the numerical ID. // A more robust implementation would look up the project by identifier if it's not a number. const response = await openProjectApi.post(`/projects/${projectId}/work_packages`, { subject, description: { raw: description || "" }, // OpenProject expects description in a specific format _links: { type: { href: type }, project: { href: `/api/v3/projects/${projectId}` } // Add other links like assignee, status, priority here } }); return { content: [ { type: "text", text: `Successfully created task: ${response.data.subject} (ID: ${response.data.id}) in project ${projectId}`, }, { type: "text", text: JSON.stringify(response.data), } ], }; } catch (error: any) { console.error("Error creating OpenProject task:", error.response?.data || error.message); const errorData = error.response?.data || { message: error.message }; return { content: [ { type: "text", text: `Error creating task: ${JSON.stringify(errorData)}`, }, ], }; } } ); // --- OpenProject Tools (Continued) --- server.tool( "openproject-get-project", "Gets a specific project by its ID from OpenProject", { projectId: z.string().describe("The ID of the project to retrieve"), }, async ({ projectId }): Promise<CallToolResult> => { try { const response = await openProjectApi.get(`/projects/${projectId}`); return { content: [ { type: "text", text: `Successfully retrieved project: ${response.data.name}`, }, { type: "text", text: JSON.stringify(response.data), } ], }; } catch (error: any) { console.error("Error getting OpenProject project:", error.response?.data || error.message); const errorData = error.response?.data || { message: error.message }; return { content: [ { type: "text", text: `Error getting project: ${JSON.stringify(errorData)}`, }, ], }; } } ); server.tool( "openproject-list-projects", "Lists all projects in OpenProject", { // No parameters for now, could add pagination/filters later pageSize: z.number().optional().describe("Number of projects per page"), offset: z.number().optional().describe("Page number to retrieve (1-indexed)") }, async ({ pageSize, offset }): Promise<CallToolResult> => { try { const params: any = {}; if (pageSize) params.pageSize = pageSize; if (offset) params.offset = offset; // OpenProject API uses offset for page number const response = await openProjectApi.get('/projects', { params }); return { content: [ { type: "text", text: `Successfully retrieved ${response.data._embedded.elements.length} projects (Total: ${response.data.total})`, }, { type: "text", text: JSON.stringify(response.data), } ], }; } catch (error: any) { console.error("Error listing OpenProject projects:", error.response?.data || error.message); const errorData = error.response?.data || { message: error.message }; return { content: [ { type: "text", text: `Error listing projects: ${JSON.stringify(errorData)}`, }, ], }; } } ); server.tool( "openproject-get-task", "Gets a specific task (work package) by its ID from OpenProject", { taskId: z.string().describe("The ID of the task to retrieve"), }, async ({ taskId }): Promise<CallToolResult> => { try { const response = await openProjectApi.get(`/work_packages/${taskId}`); return { content: [ { type: "text", text: `Successfully retrieved task: ${response.data.subject}`, }, { type: "text", text: JSON.stringify(response.data), } ], }; } catch (error: any) { console.error("Error getting OpenProject task:", error.response?.data || error.message); const errorData = error.response?.data || { message: error.message }; return { content: [ { type: "text", text: `Error getting task: ${JSON.stringify(errorData)}`, }, ], }; } } ); server.tool( "openproject-list-tasks", "Lists tasks (work packages) in OpenProject, optionally filtered by project ID", { projectId: z.string().optional().describe("Optional ID of the project to filter tasks by"), pageSize: z.number().optional().describe("Number of tasks per page"), offset: z.number().optional().describe("Page number to retrieve (1-indexed)") // We could add more filters like status, type, assignee later }, async ({ projectId, pageSize, offset }): Promise<CallToolResult> => { try { let url = '/work_packages'; const params: any = {}; if (pageSize) params.pageSize = pageSize; if (offset) params.offset = offset; if (projectId) { // If projectId is provided, OpenProject expects filters in a specific JSON format for GET requests // This typically involves a 'filters' parameter with a JSON string. // Example: filters=[{"status":{"operator":"o","values":[]}}] // For filtering by project, the endpoint /projects/{projectId}/work_packages is simpler. url = `/projects/${projectId}/work_packages`; } const response = await openProjectApi.get(url, { params }); return { content: [ { type: "text", text: `Successfully retrieved ${response.data._embedded.elements.length} tasks (Total: ${response.data.total})`, }, { type: "text", text: JSON.stringify(response.data), } ], }; } catch (error: any) { console.error("Error listing OpenProject tasks:", error.response?.data || error.message); const errorData = error.response?.data || { message: error.message }; return { content: [ { type: "text", text: `Error listing tasks: ${JSON.stringify(errorData)}`, }, ], }; } } ); server.tool( "openproject-update-project", "Updates an existing project in OpenProject. Only include fields to be changed.", { projectId: z.string().describe("The ID of the project to update"), name: z.string().optional().describe("New name for the project"), description: z.string().optional().describe("New description for the project"), // Add other updatable fields as optional parameters here }, async (params): Promise<CallToolResult> => { const { projectId, ...updatePayload } = params; if (Object.keys(updatePayload).length === 0) { return { content: [ { type: "text", text: "Error: No fields provided to update for the project." } ] } } try { // OpenProject API for project update might require a lockVersion if changes are frequent // For simplicity, we are not fetching it first. If conflicts occur, this might need adjustment. const response = await openProjectApi.patch(`/projects/${projectId}`, updatePayload); return { content: [ { type: "text", text: `Successfully updated project: ${response.data.name}`, }, { type: "text", text: JSON.stringify(response.data), } ], }; } catch (error: any) { console.error("Error updating OpenProject project:", error.response?.data || error.message); const errorData = error.response?.data || { message: error.message }; return { content: [ { type: "text", text: `Error updating project: ${JSON.stringify(errorData)}`, }, ], }; } } ); server.tool( "openproject-update-task", "Updates an existing task (work package) in OpenProject. Only include fields to be changed.", { taskId: z.string().describe("The ID of the task to update"), lockVersion: z.number().describe("The lockVersion of the task (obtained from a GET request)"), subject: z.string().optional().describe("New subject/title for the task"), description: z.string().optional().describe("New description for the task (provide as raw text)"), // Potentially add other updatable fields like type, status, assignee, parent, dates etc. // For linked resources like type or status, you'd send: _links: { type: { href: "/api/v3/types/ID" } } }, async (params): Promise<CallToolResult> => { const { taskId, lockVersion, description, ...otherFields } = params; const updatePayload: any = { lockVersion, ...otherFields }; if (description !== undefined) { updatePayload.description = { raw: description }; // OpenProject expects description in a specific format } if (Object.keys(updatePayload).filter(k => k !== 'lockVersion').length === 0) { return { content: [ { type: "text", text: "Error: No fields (besides lockVersion) provided to update for the task." } ] } } try { const response = await openProjectApi.patch(`/work_packages/${taskId}`, updatePayload); return { content: [ { type: "text", text: `Successfully updated task: ${response.data.subject}`, }, { type: "text", text: JSON.stringify(response.data), } ], }; } catch (error: any) { console.error("Error updating OpenProject task:", error.response?.data || error.message); const errorData = error.response?.data || { message: error.message }; return { content: [ { type: "text", text: `Error updating task: ${JSON.stringify(errorData)}`, }, ], }; } } ); server.tool( "openproject-delete-project", "Deletes a project from OpenProject. This action is irreversible.", { projectId: z.string().describe("The ID of the project to delete"), }, async ({ projectId }): Promise<CallToolResult> => { try { await openProjectApi.delete(`/projects/${projectId}`); return { content: [ { type: "text", text: `Successfully deleted project with ID: ${projectId}`, } ], }; } catch (error: any) { console.error("Error deleting OpenProject project:", error.response?.data || error.message); const errorData = error.response?.data || { message: error.message }; // Check if the error is a 404, meaning the project was already deleted or never existed if (error.response?.status === 404) { return { content: [ { type: "text", text: `Project with ID ${projectId} not found. It might have already been deleted.` } ] } } return { content: [ { type: "text", text: `Error deleting project: ${JSON.stringify(errorData)}`, }, ], }; } } ); server.tool( "openproject-delete-task", "Deletes a task (work package) from OpenProject. This action is irreversible.", { taskId: z.string().describe("The ID of the task to delete"), }, async ({ taskId }): Promise<CallToolResult> => { try { // Note: Deleting tasks in OpenProject might require a lockVersion or specific permissions. // The API typically returns a 204 No Content on successful deletion. await openProjectApi.delete(`/work_packages/${taskId}`); return { content: [ { type: "text", text: `Successfully deleted task with ID: ${taskId}`, } ], }; } catch (error: any) { console.error("Error deleting OpenProject task:", error.response?.data || error.message); const errorData = error.response?.data || { message: error.message }; if (error.response?.status === 404) { return { content: [ { type: "text", text: `Task with ID ${taskId} not found. It might have already been deleted.` } ] } } return { content: [ { type: "text", text: `Error deleting task: ${JSON.stringify(errorData)}`, }, ], }; } } ); return server; };

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/jessebautista/mcp-openproject'

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