feat: add attachment tools (list, upload, delete)

Add MCP tools for managing work package attachments:
- list_attachments: list all files on a work package
- upload_attachment: upload local files with multipart/form-data
- delete_attachment: remove an attachment by ID

Includes embed syntax hints (macro + markdown) in upload response
and enhanced description fields with attachment embedding guidance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 12:37:03 +08:00
parent 0c6ed9f14e
commit 36d5632ed9
6 changed files with 207 additions and 6 deletions
+55
View File
@@ -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<PaginatedResponse<any>> {
return this.request("GET", `/work_packages/${workPackageId}/attachments`);
}
async uploadAttachment(
workPackageId: number,
filePath: string,
fileName?: string,
description?: string,
): Promise<any> {
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<void> {
return this.request("DELETE", `/attachments/${attachmentId}`);
}
}
+87 -1
View File
@@ -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);
+4 -2
View File
@@ -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",
+46 -3
View File
@@ -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;