diff --git a/.serena/project.yml b/.serena/project.yml index 678d359..3774093 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -133,3 +133,17 @@ symbol_info_budget: # list of regex patterns which, when matched, mark a memory entry as read‑only. # Extends the list from the global configuration, merging the two lists. read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} diff --git a/CLAUDE.md b/CLAUDE.md index 57adafa..39fa5d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,7 @@ just bump # Bump version based on commit history Projects: `list_projects`, `get_project` Work Packages: `list_work_packages`, `get_work_package`, `create_work_package`, `update_work_package`, `add_work_package_comment` +Attachments: `list_attachments`, `upload_attachment`, `delete_attachment` Relations: `list_relations`, `create_relation`, `delete_relation` Lookup: `list_statuses`, `list_types`, `list_priorities` Users: `get_me`, `list_users` diff --git a/src/client.ts b/src/client.ts index 5c24686..777c148 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,6 +2,9 @@ * OpenProject API v3 HTTP client with Basic Auth (API key). */ +import { readFile } from "node:fs/promises"; +import { basename } from "node:path"; + export interface OpenProjectConfig { baseUrl: string; apiKey: string; @@ -239,4 +242,56 @@ export class OpenProjectClient { { comment: { raw: comment } }, ); } + + // --- Attachments --- + + async listAttachments(workPackageId: number): Promise> { + return this.request("GET", `/work_packages/${workPackageId}/attachments`); + } + + async uploadAttachment( + workPackageId: number, + filePath: string, + fileName?: string, + description?: string, + ): Promise { + const fileBuffer = await readFile(filePath); + const resolvedName = fileName ?? basename(filePath); + + const metadata = JSON.stringify({ + fileName: resolvedName, + ...(description ? { description: { raw: description } } : {}), + }); + + const formData = new FormData(); + formData.append("metadata", metadata); + formData.append( + "file", + new Blob([fileBuffer]), + resolvedName, + ); + + const url = `${this.baseUrl}/work_packages/${workPackageId}/attachments`; + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: this.headers.Authorization, + // Do NOT set Content-Type — fetch sets it with the boundary for multipart + }, + body: formData, + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `OpenProject API POST /work_packages/${workPackageId}/attachments failed (${response.status}): ${errorBody}`, + ); + } + + return (await response.json()) as any; + } + + async deleteAttachment(attachmentId: number): Promise { + return this.request("DELETE", `/attachments/${attachmentId}`); + } } diff --git a/src/handlers.ts b/src/handlers.ts index 1979cf3..0e837a5 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -48,7 +48,18 @@ function formatPaginatedList( return [header, "", ...elements.map(formatter)].join("\n"); } -export function createHandlers(client: OpenProjectClient) { +/** Build the URL-path prefix for attachment content links (e.g. "/openproject"). */ +function getBasePath(baseUrl: string): string { + try { + const url = new URL(baseUrl); + return url.pathname.replace(/\/+$/, ""); // e.g. "/openproject" or "" + } catch { + return ""; + } +} + +export function createHandlers(client: OpenProjectClient, baseUrl: string) { + const basePath = getBasePath(baseUrl); return { // --- Projects --- async list_projects(args: { offset?: number; pageSize?: number }) { @@ -323,6 +334,81 @@ export function createHandlers(client: OpenProjectClient) { .join("\n") || "No versions found."; }, + // --- Attachments --- + async list_attachments(args: { workPackageId: number }) { + const result = await client.listAttachments(args.workPackageId); + const elements = result._embedded?.elements ?? []; + if (elements.length === 0) { + return `No attachments found for work package #${args.workPackageId}.`; + } + return elements + .map((a: any) => { + const size = a.fileSize + ? `${(a.fileSize / 1024).toFixed(1)} KB` + : "Unknown size"; + const author = a._links?.author?.title ?? "Unknown"; + const downloadUrl = a._links?.downloadLocation?.href ?? "N/A"; + return [ + `${a.id}: ${a.fileName}`, + ` Type: ${a.contentType ?? "Unknown"} | Size: ${size}`, + ` Author: ${author} | Created: ${a.createdAt?.split("T")[0] ?? "Unknown"}`, + a.description?.raw ? ` Description: ${a.description.raw}` : null, + ` Download: ${downloadUrl}`, + ] + .filter(Boolean) + .join("\n"); + }) + .join("\n"); + }, + + async upload_attachment(args: { + workPackageId: number; + filePath: string; + fileName?: string; + description?: string; + }) { + const attachment = await client.uploadAttachment( + args.workPackageId, + args.filePath, + args.fileName, + args.description, + ); + const size = attachment.fileSize + ? `${(attachment.fileSize / 1024).toFixed(1)} KB` + : "Unknown size"; + const contentPath = `${basePath}/api/v3/attachments/${attachment.id}/content`; + const isImage = (attachment.contentType ?? "").startsWith("image/"); + const embedLines = isImage + ? [ + `To embed in description or comment (pick one):`, + ` Macro (preferred): attachment:${attachment.fileName}`, + ` Markdown: ![${attachment.fileName}](${contentPath})`, + ] + : [ + `To link in description or comment (pick one):`, + ` Macro (preferred): attachment:${attachment.fileName}`, + ` Markdown: [${attachment.fileName}](${contentPath})`, + ]; + return [ + `Uploaded attachment to work package #${args.workPackageId}:`, + ` ID: ${attachment.id}`, + ` File: ${attachment.fileName}`, + ` Type: ${attachment.contentType ?? "Unknown"} | Size: ${size}`, + attachment.description?.raw + ? ` Description: ${attachment.description.raw}` + : null, + ``, + ...embedLines, + ] + .filter((line) => line !== null) + .join("\n"); + }, + + async delete_attachment(args: { attachmentId: number }) { + await client.deleteAttachment(args.attachmentId); + return `Deleted attachment #${args.attachmentId}.`; + }, + // --- Activities --- async list_work_package_activities(args: { workPackageId: number }) { const result = await client.listActivities(args.workPackageId); diff --git a/src/index.ts b/src/index.ts index 3638d08..428d1f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,12 +24,14 @@ function getEnvOrThrow(name: string): string { return value; } +const baseUrl = getEnvOrThrow("OPENPROJECT_URL"); + const client = new OpenProjectClient({ - baseUrl: getEnvOrThrow("OPENPROJECT_URL"), + baseUrl, apiKey: getEnvOrThrow("OPENPROJECT_API_KEY"), }); -const handlers = createHandlers(client); +const handlers = createHandlers(client, baseUrl); const server = new McpServer({ name: "openproject", diff --git a/src/tools.ts b/src/tools.ts index 7bf4864..93cec24 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -78,7 +78,9 @@ export const toolDefs = { description: z .string() .optional() - .describe("Work package description (markdown supported)"), + .describe( + 'Work package description (CommonMark markdown). To embed uploaded images use the macro: attachment:filename.png (preferred) or markdown: ![alt](/api/v3/attachments/{id}/content). For file links use: [label](path). Upload the file first with upload_attachment.', + ), typeHref: z .string() .optional() @@ -113,7 +115,12 @@ export const toolDefs = { inputSchema: { id: z.number().describe("Work package ID"), subject: z.string().optional().describe("New title/subject"), - description: z.string().optional().describe("New description"), + description: z + .string() + .optional() + .describe( + 'New description (CommonMark markdown). To embed uploaded images use the macro: attachment:filename.png (preferred) or markdown: ![alt](/api/v3/attachments/{id}/content). For file links use: [label](path). Upload the file first with upload_attachment.', + ), statusHref: z.string().optional().describe("New status API href"), assigneeHref: z.string().optional().describe("New assignee API href"), priorityHref: z.string().optional().describe("New priority API href"), @@ -134,7 +141,11 @@ export const toolDefs = { description: "Add a comment to a work package.", inputSchema: { workPackageId: z.number().describe("Work package ID"), - comment: z.string().describe("Comment text (markdown supported)"), + comment: z + .string() + .describe( + 'Comment text (CommonMark markdown). To embed uploaded images use markdown: ![alt](/api/v3/attachments/{id}/content) or the macro: attachment:filename.png. For file links use: [label](path). Upload the file first with upload_attachment.', + ), }, }, @@ -265,4 +276,36 @@ export const toolDefs = { workPackageId: z.number().describe("Work package ID"), }, }, + // --- Attachments --- + list_attachments: { + description: + "List all file attachments on a work package. Returns file name, size, content type, and download URL.", + inputSchema: { + workPackageId: z.number().describe("Work package ID"), + }, + }, + + upload_attachment: { + description: + "Upload a file attachment to a work package. Provide the local file path on disk. Returns the attachment ID and ready-to-use embed syntax (macro and markdown) for use in descriptions and comments. Upload BEFORE referencing the attachment in a description or comment.", + inputSchema: { + workPackageId: z.number().describe("Work package ID to attach the file to"), + filePath: z.string().describe("Absolute path to the file on disk"), + fileName: z + .string() + .optional() + .describe("Override file name (defaults to the file's basename)"), + description: z + .string() + .optional() + .describe("Description of the attachment"), + }, + }, + + delete_attachment: { + description: "Delete an attachment by its ID.", + inputSchema: { + attachmentId: z.number().describe("Attachment ID to delete"), + }, + }, } as const;