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:
@@ -133,3 +133,17 @@ symbol_info_budget:
|
|||||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
# 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.
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
read_only_memory_patterns: []
|
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: {}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ just bump # Bump version based on commit history
|
|||||||
|
|
||||||
Projects: `list_projects`, `get_project`
|
Projects: `list_projects`, `get_project`
|
||||||
Work Packages: `list_work_packages`, `get_work_package`, `create_work_package`, `update_work_package`, `add_work_package_comment`
|
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`
|
Relations: `list_relations`, `create_relation`, `delete_relation`
|
||||||
Lookup: `list_statuses`, `list_types`, `list_priorities`
|
Lookup: `list_statuses`, `list_types`, `list_priorities`
|
||||||
Users: `get_me`, `list_users`
|
Users: `get_me`, `list_users`
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
* OpenProject API v3 HTTP client with Basic Auth (API key).
|
* OpenProject API v3 HTTP client with Basic Auth (API key).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { basename } from "node:path";
|
||||||
|
|
||||||
export interface OpenProjectConfig {
|
export interface OpenProjectConfig {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
@@ -239,4 +242,56 @@ export class OpenProjectClient {
|
|||||||
{ comment: { raw: comment } },
|
{ 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
@@ -48,7 +48,18 @@ function formatPaginatedList(
|
|||||||
return [header, "", ...elements.map(formatter)].join("\n");
|
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 {
|
return {
|
||||||
// --- Projects ---
|
// --- Projects ---
|
||||||
async list_projects(args: { offset?: number; pageSize?: number }) {
|
async list_projects(args: { offset?: number; pageSize?: number }) {
|
||||||
@@ -323,6 +334,81 @@ export function createHandlers(client: OpenProjectClient) {
|
|||||||
.join("\n") || "No versions found.";
|
.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: `,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
`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 ---
|
// --- Activities ---
|
||||||
async list_work_package_activities(args: { workPackageId: number }) {
|
async list_work_package_activities(args: { workPackageId: number }) {
|
||||||
const result = await client.listActivities(args.workPackageId);
|
const result = await client.listActivities(args.workPackageId);
|
||||||
|
|||||||
+4
-2
@@ -24,12 +24,14 @@ function getEnvOrThrow(name: string): string {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseUrl = getEnvOrThrow("OPENPROJECT_URL");
|
||||||
|
|
||||||
const client = new OpenProjectClient({
|
const client = new OpenProjectClient({
|
||||||
baseUrl: getEnvOrThrow("OPENPROJECT_URL"),
|
baseUrl,
|
||||||
apiKey: getEnvOrThrow("OPENPROJECT_API_KEY"),
|
apiKey: getEnvOrThrow("OPENPROJECT_API_KEY"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handlers = createHandlers(client);
|
const handlers = createHandlers(client, baseUrl);
|
||||||
|
|
||||||
const server = new McpServer({
|
const server = new McpServer({
|
||||||
name: "openproject",
|
name: "openproject",
|
||||||
|
|||||||
+46
-3
@@ -78,7 +78,9 @@ export const toolDefs = {
|
|||||||
description: z
|
description: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.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: . For file links use: [label](path). Upload the file first with upload_attachment.',
|
||||||
|
),
|
||||||
typeHref: z
|
typeHref: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -113,7 +115,12 @@ export const toolDefs = {
|
|||||||
inputSchema: {
|
inputSchema: {
|
||||||
id: z.number().describe("Work package ID"),
|
id: z.number().describe("Work package ID"),
|
||||||
subject: z.string().optional().describe("New title/subject"),
|
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: . For file links use: [label](path). Upload the file first with upload_attachment.',
|
||||||
|
),
|
||||||
statusHref: z.string().optional().describe("New status API href"),
|
statusHref: z.string().optional().describe("New status API href"),
|
||||||
assigneeHref: z.string().optional().describe("New assignee API href"),
|
assigneeHref: z.string().optional().describe("New assignee API href"),
|
||||||
priorityHref: z.string().optional().describe("New priority 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.",
|
description: "Add a comment to a work package.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
workPackageId: z.number().describe("Work package ID"),
|
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:  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"),
|
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;
|
} as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user