@denisixnpm/planka-mcp 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,8 +11,25 @@ No build step, no Docker required — `npx` runs the prebuilt package over stdio
11
11
 
12
12
  ## Install
13
13
 
14
+ Two auth modes: **username/password** works on every Planka version (the server logs in
15
+ automatically, caches the token, and silently re-logins on expiry); **API key** is Planka **v2
16
+ only** — generate one in your Planka user settings → *API keys* (v1.26.x has no API keys; verified:
17
+ `X-Api-Key` returns 401 there).
18
+
14
19
  ### Claude Code
15
20
 
21
+ **Planka v1 (≤ 1.26.x) — username/password:**
22
+
23
+ ```bash
24
+ claude mcp add planka \
25
+ --env PLANKA_BASE_URL=http://localhost:3000 \
26
+ --env PLANKA_USERNAME=your-email-or-username \
27
+ --env PLANKA_PASSWORD=your-password \
28
+ -- npx -y @denisixnpm/planka-mcp
29
+ ```
30
+
31
+ **Planka v2 — API key (username/password above works too):**
32
+
16
33
  ```bash
17
34
  claude mcp add planka \
18
35
  --env PLANKA_BASE_URL=http://localhost:3000 \
@@ -20,7 +37,7 @@ claude mcp add planka \
20
37
  -- npx -y @denisixnpm/planka-mcp
21
38
  ```
22
39
 
23
- Or in `claude_desktop_config.json` / `.mcp.json`:
40
+ Or in `claude_desktop_config.json` / `.mcp.json` (username/password form — works on both versions):
24
41
 
25
42
  ```json
26
43
  {
@@ -28,7 +45,7 @@ Or in `claude_desktop_config.json` / `.mcp.json`:
28
45
  "planka": {
29
46
  "command": "npx",
30
47
  "args": ["-y", "@denisixnpm/planka-mcp"],
31
- "env": { "PLANKA_BASE_URL": "http://localhost:3000", "PLANKA_API_KEY": "your-api-key" }
48
+ "env": { "PLANKA_BASE_URL": "http://localhost:3000", "PLANKA_USERNAME": "your-email-or-username", "PLANKA_PASSWORD": "your-password" }
32
49
  }
33
50
  }
34
51
  }
@@ -36,6 +53,17 @@ Or in `claude_desktop_config.json` / `.mcp.json`:
36
53
 
37
54
  ### Codex (`~/.codex/config.toml`)
38
55
 
56
+ **Planka v1 (≤ 1.26.x):**
57
+
58
+ ```toml
59
+ [mcp_servers.planka]
60
+ command = "npx"
61
+ args = ["-y", "@denisixnpm/planka-mcp"]
62
+ env = { PLANKA_BASE_URL = "http://localhost:3000", PLANKA_USERNAME = "your-email-or-username", PLANKA_PASSWORD = "your-password" }
63
+ ```
64
+
65
+ **Planka v2 (API key):**
66
+
39
67
  ```toml
40
68
  [mcp_servers.planka]
41
69
  command = "npx"
@@ -45,6 +73,22 @@ env = { PLANKA_BASE_URL = "http://localhost:3000", PLANKA_API_KEY = "your-api-ke
45
73
 
46
74
  ### opencode (`opencode.json`)
47
75
 
76
+ **Planka v1 (≤ 1.26.x):**
77
+
78
+ ```json
79
+ {
80
+ "mcp": {
81
+ "planka": {
82
+ "type": "local",
83
+ "command": ["npx", "-y", "@denisixnpm/planka-mcp"],
84
+ "environment": { "PLANKA_BASE_URL": "http://localhost:3000", "PLANKA_USERNAME": "your-email-or-username", "PLANKA_PASSWORD": "your-password" }
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ **Planka v2 (API key):**
91
+
48
92
  ```json
49
93
  {
50
94
  "mcp": {
@@ -59,18 +103,34 @@ env = { PLANKA_BASE_URL = "http://localhost:3000", PLANKA_API_KEY = "your-api-ke
59
103
 
60
104
  Requires Node.js ≥ 22.19 (or run the same command with Bun ≥ 1.3). Debug: `npx -y @denisixnpm/planka-mcp 2>&1 | tee debug.log`.
61
105
 
106
+ ## Attachments
107
+
108
+ `cards.get` returns the full working context in one call: description, tasks, task lists,
109
+ attachments, and the activity log (`actions`). Attachment metadata is normalized across Planka
110
+ versions (v2's nested `data.url` is flattened), so every record carries `id`, `name`, `url`,
111
+ `type`, `size`, `mimeType`.
112
+
113
+ - **Create with files**: `cards { action: "create", ..., data: { attachments: [{ name, text | contentBase64, mimeType? }] } }` — uploads happen in the same tool call.
114
+ - **Upload later**: `attachments { action: "upload", id: <cardId>, data: { name, text | contentBase64 | url, mimeType? } }` — `url` creates a link attachment (Planka v2 only).
115
+ - **Download**: `attachments { action: "download", id: <attachmentId>, data: { cardId } }` — returns `{ name, mimeType, size, contentBase64 }` (≤ 10 MB inline).
116
+ - **Rename / delete**: `attachments { action: "rename" | "delete", id, data: { name? } }`.
117
+
118
+ Downloads require `PLANKA_USERNAME`/`PLANKA_PASSWORD` auth: Planka serves files from a
119
+ cookie-authenticated route that rejects `Authorization` headers and API keys (verified on 1.26.2
120
+ and 2.0.3).
121
+
62
122
  ## Configuration
63
123
 
64
124
  | Variable | Default | Description |
65
125
  |----------|---------|-------------|
66
126
  | `PLANKA_BASE_URL` | `http://localhost:3000` | Planka instance URL |
67
- | `PLANKA_API_KEY` | — | API key auth (recommended; wins if both are set) |
68
- | `PLANKA_USERNAME` / `PLANKA_PASSWORD` | — | Login auth (alternative to API key) |
127
+ | `PLANKA_USERNAME` / `PLANKA_PASSWORD` | — | Login auth **all Planka versions**; token is fetched, cached, and refreshed automatically |
128
+ | `PLANKA_API_KEY` | — | API key auth — **Planka v2 only** (user settings API keys); wins if both are set |
69
129
  | `PLANKA_HTTP_TIMEOUT_MS` | `30000` | Per-request timeout (aborts, then retries) |
70
130
  | `PLANKA_HTTP_MAX_RETRIES` | `2` | Retries for `408`/`429`/`5xx` and network errors |
71
131
  | `PLANKA_HTTP_RETRY_BASE_DELAY_MS` | `250` | Exponential backoff base (`base * 2^attempt`) |
72
132
  | `PLANKA_CONDENSED_OUTPUT` | `true` | Condensed agent output; `false` = raw payloads |
73
- | `ENABLE_ALL_TOOLS` | `false` | Enable all 28 tools (otherwise 11 core) |
133
+ | `ENABLE_ALL_TOOLS` | `false` | Enable all 28 tools (otherwise 12 core) |
74
134
  | `ENABLE_ADMIN_TOOLS` / `ENABLE_OPTIONAL_TOOLS` | `false` | Enable one extra category |
75
135
  | `MCP_TRANSPORT` | `stdio` | `stdio` (single client) or `sse` (multi-client) |
76
136
  | `MCP_PORT` / `MCP_HOST` | `3001` / `127.0.0.1` | SSE mode bind; `0.0.0.0` in Docker |
package/dist/condense.js CHANGED
@@ -30,7 +30,7 @@ const ENTITY_FIELDS = {
30
30
  labelExtras: ["id", "cardId", "labelId"],
31
31
  comments: ["id", "text", "cardId"],
32
32
  commentExtras: ["id", "text", "cardId"],
33
- attachments: ["id", "name", "url", "type", "cardId"],
33
+ attachments: ["id", "name", "url", "type", "size", "mimeType", "cardId"],
34
34
  users: ["id", "email", "name", "username", "role"],
35
35
  userInfo: ["id", "email", "name", "username", "role"],
36
36
  boardMembers: ["id", "userId", "boardId", "role"],
@@ -44,7 +44,7 @@ const ENTITY_FIELDS = {
44
44
  webhooks: ["id", "name", "url"],
45
45
  backgroundImages: ["id", "name", "boardId"],
46
46
  notifications: ["id", "type", "isRead", "cardId"],
47
- actions: ["id", "type", "cardId", "userId"],
47
+ actions: ["id", "type", "cardId", "boardId", "userId", "createdAt", "data"],
48
48
  };
49
49
  /** `included` keys that do not match their tool name verbatim. */
50
50
  const INCLUDED_KEY_TO_TOOL = {
@@ -103,6 +103,11 @@ export function condenseResult(toolName, data) {
103
103
  }
104
104
  if (typeof data === "object" && data !== null) {
105
105
  const source = data;
106
+ // Attachment downloads carry base64 payload — whitelists and text
107
+ // truncation must not touch them (size is already capped by the engine).
108
+ if (typeof source.contentBase64 === "string") {
109
+ return data;
110
+ }
106
111
  const hasItems = Array.isArray(source.items);
107
112
  const hasItem = "item" in source;
108
113
  if (hasItems || hasItem) {
package/dist/server.js CHANGED
@@ -138,6 +138,11 @@ async function executeGroupedApiCall(groupedDef, input, overridePath, retryAttem
138
138
  const validActions = Object.keys(groupedDef.operations).join(", ");
139
139
  return { success: false, error: `Invalid action '${action}'. Valid actions: ${validActions}` };
140
140
  }
141
+ // Fully custom operations (attachment download needs cookie-auth + binary
142
+ // handling that the generic JSON engine cannot express).
143
+ if (operation.custom === "downloadAttachment") {
144
+ return downloadAttachment(input, scope);
145
+ }
141
146
  // Construct URL with path parameters
142
147
  const canonicalPath = overridePath ?? operation.path;
143
148
  let actualPath = canonicalPath;
@@ -201,7 +206,38 @@ async function executeGroupedApiCall(groupedDef, input, overridePath, retryAttem
201
206
  }
202
207
  // Handle request body
203
208
  let body = undefined;
204
- if (["POST", "PUT", "PATCH"].includes(methodUpper) && input?.data !== undefined) {
209
+ if (operation.requestType === "multipart") {
210
+ // Planka file upload: v2 requires form fields `type` + `name`; v1
211
+ // ignores extra fields, so one v2-shaped request serves both majors.
212
+ const d = (input?.data ?? {});
213
+ const name = typeof d.name === "string" && d.name !== "" ? d.name : "attachment";
214
+ const isLink = typeof d.url === "string" && d.url !== "" && d.contentBase64 === undefined && d.text === undefined;
215
+ const fd = new FormData();
216
+ fd.append("type", isLink ? "link" : "file");
217
+ fd.append("name", name);
218
+ if (isLink) {
219
+ fd.append("url", String(d.url));
220
+ }
221
+ else {
222
+ let bytes;
223
+ let mimeType = typeof d.mimeType === "string" ? d.mimeType : "application/octet-stream";
224
+ if (typeof d.contentBase64 === "string" && d.contentBase64 !== "") {
225
+ const binary = atob(d.contentBase64);
226
+ bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0));
227
+ }
228
+ else if (typeof d.text === "string") {
229
+ bytes = new TextEncoder().encode(d.text);
230
+ if (typeof d.mimeType !== "string")
231
+ mimeType = "text/plain";
232
+ }
233
+ else {
234
+ return { success: false, error: "Provide data.contentBase64, data.text, or data.url for the attachment content" };
235
+ }
236
+ fd.append("file", new Blob([bytes], { type: mimeType }), name);
237
+ }
238
+ body = fd;
239
+ }
240
+ else if (["POST", "PUT", "PATCH"].includes(methodUpper) && input?.data !== undefined) {
205
241
  headers["Content-Type"] = "application/json";
206
242
  body = JSON.stringify(input.data);
207
243
  }
@@ -272,6 +308,152 @@ async function executeGroupedApiCall(groupedDef, input, overridePath, retryAttem
272
308
  };
273
309
  }
274
310
  }
311
+ /**
312
+ * Planka v2 nests attachment metadata under `data` ({url, size, mimeType});
313
+ * v1 keeps `url` top-level. Flatten to one shape for agents and the condenser.
314
+ */
315
+ function normalizeAttachment(raw) {
316
+ const item = (raw ?? {});
317
+ const nested = item.data;
318
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) {
319
+ return {
320
+ ...item,
321
+ url: item.url ?? nested.url,
322
+ size: nested.size,
323
+ mimeType: nested.mimeType,
324
+ data: undefined,
325
+ };
326
+ }
327
+ return item;
328
+ }
329
+ /** Normalize every attachment record inside a `{item|items, included}` envelope. */
330
+ function normalizeAttachmentsIn(data) {
331
+ if (typeof data !== "object" || data === null)
332
+ return;
333
+ const envelope = data;
334
+ const item = envelope.item;
335
+ if (item && item.type !== undefined && (item.cardId !== undefined || item.data !== undefined)) {
336
+ envelope.item = normalizeAttachment(item);
337
+ }
338
+ const list = envelope.included?.attachments;
339
+ if (Array.isArray(list)) {
340
+ envelope.included.attachments = list.map(normalizeAttachment);
341
+ }
342
+ }
343
+ /**
344
+ * Download an attachment's bytes. Planka serves files from
345
+ * /attachments/{id}/download/{name}, which authenticates via the
346
+ * `accessToken` cookie only — Bearer and X-Api-Key headers get 401 (verified
347
+ * against live 1.26.2 and 2.0.3). The attachment record must be located on
348
+ * its owning card first, since there is no GET /attachments/{id}.
349
+ */
350
+ const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
351
+ async function downloadAttachment(input, scope) {
352
+ if (PLANKA_API_KEY) {
353
+ return { success: false, error: "Attachment downloads require PLANKA_USERNAME/PLANKA_PASSWORD auth: Planka serves files from a cookie-authenticated route that rejects API keys" };
354
+ }
355
+ const attachmentId = String(input?.id ?? "");
356
+ const cardId = String(input?.data?.cardId ?? scope?.cardId ?? "");
357
+ if (!attachmentId || !cardId) {
358
+ return { success: false, error: "Provide id (attachment ID) and data.cardId (owning card), or select the card via the context tool" };
359
+ }
360
+ const cardsTool = toolMap.get("cards");
361
+ if (!cardsTool) {
362
+ return { success: false, error: "cards tool unavailable; cannot resolve attachment" };
363
+ }
364
+ const cardRes = await executeGroupedApiCall(cardsTool.groupedDef, { action: "get", id: cardId });
365
+ if (!cardRes.success) {
366
+ return cardRes;
367
+ }
368
+ const found = (cardRes.data?.included?.attachments ?? []).find(a => String(a.id) === attachmentId);
369
+ if (!found) {
370
+ return { success: false, error: `Attachment ${attachmentId} not found on card ${cardId}` };
371
+ }
372
+ const record = normalizeAttachment(found);
373
+ if (record.type === "link" || typeof record.url !== "string" || record.url === "") {
374
+ return { success: true, data: { ...record, note: "link attachment — follow data.url" } };
375
+ }
376
+ const target = new URL(String(record.url), PLANKA_BASE_URL);
377
+ const token = await getAccessToken();
378
+ let res;
379
+ try {
380
+ res = await fetch(target, {
381
+ headers: { Authorization: `Bearer ${token}`, cookie: `accessToken=${token}` },
382
+ signal: AbortSignal.timeout(PLANKA_HTTP_TIMEOUT_MS),
383
+ });
384
+ }
385
+ catch (downloadError) {
386
+ return { success: false, error: `Attachment download failed: ${downloadError instanceof Error ? downloadError.message : String(downloadError)}` };
387
+ }
388
+ if (!res.ok) {
389
+ return { success: false, error: `Attachment download failed: HTTP ${res.status}` };
390
+ }
391
+ const bytes = new Uint8Array(await res.arrayBuffer());
392
+ if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
393
+ return { success: false, error: `Attachment too large to inline (${bytes.byteLength} bytes > ${MAX_ATTACHMENT_BYTES}); fetch it directly: ${target}` };
394
+ }
395
+ return {
396
+ success: true,
397
+ data: {
398
+ id: attachmentId,
399
+ name: record.name,
400
+ size: bytes.byteLength,
401
+ mimeType: res.headers.get("content-type")?.split(";")[0] ?? record.mimeType ?? "application/octet-stream",
402
+ contentBase64: Buffer.from(bytes).toString("base64"),
403
+ },
404
+ };
405
+ }
406
+ /**
407
+ * Post-processing for card calls:
408
+ * - `get`: fold the activity log (`actions`) into `included` so one call
409
+ * returns the full working context — description, tasks, attachments,
410
+ * actions. Best-effort: an actions failure never fails the card get.
411
+ * - `create`: upload any `data.attachments` entries and fold them into
412
+ * `included.attachments`.
413
+ */
414
+ async function enrichCardResult(action, data, pendingAttachments, cardId, scope) {
415
+ normalizeAttachmentsIn(data);
416
+ const cardsTool = toolMap.get("cards");
417
+ if (action === "get" && cardsTool && cardId) {
418
+ try {
419
+ const acts = await executeGroupedApiCall(cardsTool.groupedDef, { action: "getActions", id: cardId }, undefined, 0, false, false, scope);
420
+ if (acts.success && Array.isArray(acts.data?.items)) {
421
+ if (!data.included || typeof data.included !== "object")
422
+ data.included = {};
423
+ data.included.actions = acts.data.items;
424
+ }
425
+ }
426
+ catch (actionsError) {
427
+ console.error(`[cards.get] actions fetch failed for card ${cardId}: ${actionsError instanceof Error ? actionsError.message : String(actionsError)}`);
428
+ }
429
+ }
430
+ if (action === "create" && pendingAttachments && pendingAttachments.length > 0) {
431
+ const createdCardId = String(data?.item?.id ?? cardId ?? "");
432
+ const attachmentsTool = toolMap.get("attachments");
433
+ if (createdCardId && attachmentsTool) {
434
+ const uploaded = [];
435
+ const errors = [];
436
+ for (const att of pendingAttachments) {
437
+ const up = await executeGroupedApiCall(attachmentsTool.groupedDef, { action: "upload", id: createdCardId, data: att });
438
+ if (up.success) {
439
+ uploaded.push(normalizeAttachment(up.data?.item));
440
+ }
441
+ else {
442
+ errors.push({ name: String(att?.name ?? "attachment"), error: up.error ?? "upload failed" });
443
+ }
444
+ }
445
+ if (uploaded.length > 0) {
446
+ if (!data.included || typeof data.included !== "object")
447
+ data.included = {};
448
+ data.included.attachments = [...(data.included.attachments ?? []), ...uploaded];
449
+ }
450
+ if (errors.length > 0) {
451
+ data.attachmentUploadErrors = errors;
452
+ }
453
+ }
454
+ }
455
+ return data;
456
+ }
275
457
  /**
276
458
  * Local handler for the `context` tool: maintains the working scope of this
277
459
  * server instance (per SSE session, process-wide for stdio). Enriches `set`
@@ -342,7 +524,7 @@ if (!process.argv.includes("--healthcheck")) {
342
524
  function createMcpServer() {
343
525
  const server = new McpServer({
344
526
  name: "planka-mcp",
345
- version: "2.3.0",
527
+ version: "2.4.0",
346
528
  }, {
347
529
  capabilities: {
348
530
  tools: {},
@@ -366,9 +548,25 @@ function createMcpServer() {
366
548
  isError: true,
367
549
  };
368
550
  }
551
+ // cards.create accepts data.attachments — strip them from the API payload
552
+ // and upload after the card exists.
553
+ let pendingAttachments = null;
554
+ const reqArgs = (args ?? {});
555
+ const reqData = reqArgs.data;
556
+ if (toolDef.groupedDef.name === "cards" && reqArgs.action === "create" && Array.isArray(reqData?.attachments)) {
557
+ pendingAttachments = reqData.attachments;
558
+ const { attachments: _omit, ...dataWithout } = reqData;
559
+ reqArgs.data = dataWithout;
560
+ }
369
561
  const result = toolDef.groupedDef.name === "context"
370
562
  ? await handleContextCall(args, scope)
371
563
  : await executeGroupedApiCall(toolDef.groupedDef, args, undefined, 0, false, true, scope);
564
+ if (result.success && toolDef.groupedDef.name === "attachments") {
565
+ normalizeAttachmentsIn(result.data);
566
+ }
567
+ if (result.success && toolDef.groupedDef.name === "cards") {
568
+ result.data = await enrichCardResult(String(args?.action ?? ""), result.data, pendingAttachments, typeof args?.id === "string" ? args.id : undefined, scope);
569
+ }
372
570
  if (result.success) {
373
571
  const wantsRaw = !PLANKA_CONDENSED_OUTPUT || args?.raw === true;
374
572
  const payload = wantsRaw ? result.data : condenseResult(toolDef.groupedDef.name, result.data);
@@ -2,8 +2,8 @@
2
2
  * Core tools - Essential for basic Kanban operations (always enabled)
3
3
  */
4
4
  import { GroupedToolDefinition } from "../types.js";
5
- import { authTool, projectsTool, boardsTool, listsTool, cardsTool, commentsTool, tasksTool, labelsTool, cardMembersTool, bootstrapTool, contextTool } from "./tools.js";
6
- export { authTool, projectsTool, boardsTool, listsTool, cardsTool, commentsTool, tasksTool, labelsTool, cardMembersTool, bootstrapTool, contextTool, };
5
+ import { authTool, projectsTool, boardsTool, listsTool, cardsTool, attachmentsTool, commentsTool, tasksTool, labelsTool, cardMembersTool, bootstrapTool, contextTool } from "./tools.js";
6
+ export { authTool, projectsTool, boardsTool, listsTool, cardsTool, attachmentsTool, commentsTool, tasksTool, labelsTool, cardMembersTool, bootstrapTool, contextTool, };
7
7
  /**
8
8
  * All core tools combined
9
9
  */
@@ -1,5 +1,5 @@
1
- import { authTool, projectsTool, boardsTool, listsTool, cardsTool, commentsTool, tasksTool, labelsTool, cardMembersTool, bootstrapTool, contextTool, } from "./tools.js";
2
- export { authTool, projectsTool, boardsTool, listsTool, cardsTool, commentsTool, tasksTool, labelsTool, cardMembersTool, bootstrapTool, contextTool, };
1
+ import { authTool, projectsTool, boardsTool, listsTool, cardsTool, attachmentsTool, commentsTool, tasksTool, labelsTool, cardMembersTool, bootstrapTool, contextTool, } from "./tools.js";
2
+ export { authTool, projectsTool, boardsTool, listsTool, cardsTool, attachmentsTool, commentsTool, tasksTool, labelsTool, cardMembersTool, bootstrapTool, contextTool, };
3
3
  /**
4
4
  * All core tools combined
5
5
  */
@@ -11,6 +11,7 @@ export const coreTools = [
11
11
  boardsTool,
12
12
  listsTool,
13
13
  cardsTool,
14
+ attachmentsTool,
14
15
  commentsTool,
15
16
  tasksTool,
16
17
  labelsTool,
@@ -19,6 +19,20 @@ export declare const listsTool: GroupedToolDefinition;
19
19
  * Cards tool - manages cards within lists
20
20
  */
21
21
  export declare const cardsTool: GroupedToolDefinition;
22
+ /**
23
+ * Attachments tool - upload, download, and manage card file attachments.
24
+ *
25
+ * Verified against live Planka 1.26.2 and 2.0.3:
26
+ * - upload: POST /cards/{cardId}/attachments, multipart. v2 requires form
27
+ * fields `type` ("file"|"link") and `name`; v1 ignores extra fields, so a
28
+ * single v2-shaped request works on both.
29
+ * - download: no JSON API — the attachment record carries an `url`
30
+ * (/attachments/{id}/download/{name}) that Planka authenticates via the
31
+ * `accessToken` cookie only (Bearer/X-Api-Key get 401). The engine fetches
32
+ * it and returns base64.
33
+ * - link attachments (url instead of file content) are Planka v2 only.
34
+ */
35
+ export declare const attachmentsTool: GroupedToolDefinition;
22
36
  /**
23
37
  * Comments tool - manages comments on cards
24
38
  */
@@ -231,7 +231,12 @@ export const cardsTool = {
231
231
  get: {
232
232
  method: "GET",
233
233
  path: "/cards/{id}",
234
- description: "Get card with task lists, attachments, and custom fields",
234
+ description: "Get card with description, task lists, tasks, attachments, and custom fields",
235
+ },
236
+ getActions: {
237
+ method: "GET",
238
+ path: "/cards/{cardId}/actions",
239
+ description: "Get the card's activity log (actions: created, moved, commented, ...)",
235
240
  },
236
241
  create: {
237
242
  method: "POST",
@@ -249,19 +254,20 @@ export const cardsTool = {
249
254
  description: "Delete a card permanently",
250
255
  },
251
256
  },
252
- inputSchema: buildGroupedSchema(["list", "get", "create", "update", "delete"], {
257
+ inputSchema: buildGroupedSchema(["list", "get", "getActions", "create", "update", "delete"], {
253
258
  list: "Get cards from a list (requires listId)",
254
259
  get: "Get card details by ID",
260
+ getActions: "Get card activity log",
255
261
  create: "Create a new card",
256
262
  update: "Update card properties",
257
263
  delete: "Delete a card",
258
264
  }, {
259
265
  id: {
260
- description: "Card ID (for get, update, delete) or List ID (for list, create)",
261
- requiredFor: ["list", "get", "update", "delete"],
266
+ description: "Card ID (for get, getActions, update, delete) or List ID (for list, create)",
267
+ requiredFor: ["list", "get", "getActions", "update", "delete"],
262
268
  },
263
269
  data: {
264
- description: "Card data: { name: string, type: 'project'|'story' (required), position: number (required for create), listId?: string (for create/move), description?: string, dueDate?: string, isDueCompleted?: boolean }",
270
+ description: "Card data: { name: string, type: 'project'|'story' (required), position: number (required for create), listId?: string (for create/move), description?: string, dueDate?: string, isDueCompleted?: boolean, attachments?: [{ name, contentBase64 | text | url, mimeType? }] }",
265
271
  requiredFor: ["create", "update"],
266
272
  properties: {
267
273
  name: { type: "string", description: "Card name", required: true },
@@ -271,6 +277,7 @@ export const cardsTool = {
271
277
  description: { type: "string", description: "Card description" },
272
278
  dueDate: { type: "string", description: "Due date (ISO 8601)" },
273
279
  isDueCompleted: { type: "boolean", description: "Whether the due date is completed" },
280
+ attachments: { type: "array", description: "Files to attach on create: [{ name, contentBase64 (base64 of file bytes) | text (plain text content) | url (link, Planka v2), mimeType? }] (Planka v1 ignores type)" },
274
281
  },
275
282
  },
276
283
  query: {
@@ -280,6 +287,70 @@ export const cardsTool = {
280
287
  },
281
288
  }),
282
289
  };
290
+ /**
291
+ * Attachments tool - upload, download, and manage card file attachments.
292
+ *
293
+ * Verified against live Planka 1.26.2 and 2.0.3:
294
+ * - upload: POST /cards/{cardId}/attachments, multipart. v2 requires form
295
+ * fields `type` ("file"|"link") and `name`; v1 ignores extra fields, so a
296
+ * single v2-shaped request works on both.
297
+ * - download: no JSON API — the attachment record carries an `url`
298
+ * (/attachments/{id}/download/{name}) that Planka authenticates via the
299
+ * `accessToken` cookie only (Bearer/X-Api-Key get 401). The engine fetches
300
+ * it and returns base64.
301
+ * - link attachments (url instead of file content) are Planka v2 only.
302
+ */
303
+ export const attachmentsTool = {
304
+ name: "attachments",
305
+ description: "Upload, download, and manage file attachments on Planka cards. Content moves as base64 (data.contentBase64 for upload, result.contentBase64 for download).",
306
+ operations: {
307
+ upload: {
308
+ method: "POST",
309
+ path: "/cards/{cardId}/attachments",
310
+ requestType: "multipart",
311
+ description: "Upload a file (contentBase64 or text) or attach a link (url, Planka v2) to a card",
312
+ },
313
+ download: {
314
+ method: "GET",
315
+ path: "/attachments/{id}",
316
+ custom: "downloadAttachment",
317
+ description: "Download an attachment's content as base64 (requires the owning card ID)",
318
+ },
319
+ rename: {
320
+ method: "PATCH",
321
+ path: "/attachments/{id}",
322
+ description: "Rename an attachment",
323
+ },
324
+ delete: {
325
+ method: "DELETE",
326
+ path: "/attachments/{id}",
327
+ description: "Delete an attachment permanently",
328
+ },
329
+ },
330
+ inputSchema: buildGroupedSchema(["upload", "download", "rename", "delete"], {
331
+ upload: "Upload a file or link to a card (id = card ID)",
332
+ download: "Download attachment content as base64 (id = attachment ID)",
333
+ rename: "Rename an attachment",
334
+ delete: "Delete an attachment",
335
+ }, {
336
+ id: {
337
+ description: "Card ID (for upload) or Attachment ID (for download, rename, delete)",
338
+ requiredFor: ["upload", "download", "rename", "delete"],
339
+ },
340
+ data: {
341
+ description: "Attachment data: upload = { name (required), contentBase64 | text | url, mimeType? }; download = { cardId? (owning card, or set via context) }; rename = { name }",
342
+ requiredFor: ["upload", "rename"],
343
+ properties: {
344
+ name: { type: "string", description: "File/attachment name (e.g. report.pdf)", required: true },
345
+ contentBase64: { type: "string", description: "Base64-encoded file content (preferred for binary)" },
346
+ text: { type: "string", description: "Plain-text content (alternative to contentBase64)" },
347
+ mimeType: { type: "string", description: "MIME type (e.g. application/pdf); default application/octet-stream, text/plain for text" },
348
+ url: { type: "string", description: "Link attachments (Planka v2): attach an external URL instead of file content" },
349
+ cardId: { type: "string", description: "Owning card ID for download (or set cardId via the context tool)" },
350
+ },
351
+ },
352
+ }),
353
+ };
283
354
  /**
284
355
  * Comments tool - manages comments on cards
285
356
  */
@@ -2,8 +2,8 @@
2
2
  * Optional tools - Extended functionality (not essential for basic operations)
3
3
  */
4
4
  import { GroupedToolDefinition } from "../types.js";
5
- import { actionsTool, attachmentsTool, boardMembersTool, customFieldsTool, notificationsTool, backgroundImagesTool, cardExtrasTool, commentExtrasTool, listExtrasTool, taskExtrasTool, labelExtrasTool, cardMemberExtrasTool, userInfoTool } from "./tools.js";
6
- export { actionsTool, attachmentsTool, boardMembersTool, customFieldsTool, notificationsTool, backgroundImagesTool, cardExtrasTool, commentExtrasTool, listExtrasTool, taskExtrasTool, labelExtrasTool, cardMemberExtrasTool, userInfoTool, };
5
+ import { actionsTool, boardMembersTool, customFieldsTool, notificationsTool, backgroundImagesTool, cardExtrasTool, commentExtrasTool, listExtrasTool, taskExtrasTool, labelExtrasTool, cardMemberExtrasTool, userInfoTool } from "./tools.js";
6
+ export { actionsTool, boardMembersTool, customFieldsTool, notificationsTool, backgroundImagesTool, cardExtrasTool, commentExtrasTool, listExtrasTool, taskExtrasTool, labelExtrasTool, cardMemberExtrasTool, userInfoTool, };
7
7
  /**
8
8
  * All optional tools combined
9
9
  */
@@ -1,11 +1,10 @@
1
- import { actionsTool, attachmentsTool, boardMembersTool, customFieldsTool, notificationsTool, backgroundImagesTool, cardExtrasTool, commentExtrasTool, listExtrasTool, taskExtrasTool, labelExtrasTool, cardMemberExtrasTool, userInfoTool, } from "./tools.js";
2
- export { actionsTool, attachmentsTool, boardMembersTool, customFieldsTool, notificationsTool, backgroundImagesTool, cardExtrasTool, commentExtrasTool, listExtrasTool, taskExtrasTool, labelExtrasTool, cardMemberExtrasTool, userInfoTool, };
1
+ import { actionsTool, boardMembersTool, customFieldsTool, notificationsTool, backgroundImagesTool, cardExtrasTool, commentExtrasTool, listExtrasTool, taskExtrasTool, labelExtrasTool, cardMemberExtrasTool, userInfoTool, } from "./tools.js";
2
+ export { actionsTool, boardMembersTool, customFieldsTool, notificationsTool, backgroundImagesTool, cardExtrasTool, commentExtrasTool, listExtrasTool, taskExtrasTool, labelExtrasTool, cardMemberExtrasTool, userInfoTool, };
3
3
  /**
4
4
  * All optional tools combined
5
5
  */
6
6
  export const optionalTools = [
7
7
  actionsTool,
8
- attachmentsTool,
9
8
  boardMembersTool,
10
9
  customFieldsTool,
11
10
  notificationsTool,
@@ -3,10 +3,6 @@ import { GroupedToolDefinition } from "../types.js";
3
3
  * Actions tool - action history
4
4
  */
5
5
  export declare const actionsTool: GroupedToolDefinition;
6
- /**
7
- * Attachments tool - manages card attachments
8
- */
9
- export declare const attachmentsTool: GroupedToolDefinition;
10
6
  /**
11
7
  * Board Members tool - manages board memberships
12
8
  */
@@ -30,49 +30,6 @@ export const actionsTool = {
30
30
  },
31
31
  }),
32
32
  };
33
- /**
34
- * Attachments tool - manages card attachments
35
- */
36
- export const attachmentsTool = {
37
- name: "attachments",
38
- description: "Manage attachments on Planka cards.",
39
- operations: {
40
- create: {
41
- method: "POST",
42
- path: "/cards/{cardId}/attachments",
43
- description: "Add an attachment to a card",
44
- },
45
- update: {
46
- method: "PATCH",
47
- path: "/attachments/{id}",
48
- description: "Update attachment properties",
49
- },
50
- delete: {
51
- method: "DELETE",
52
- path: "/attachments/{id}",
53
- description: "Delete an attachment",
54
- },
55
- },
56
- inputSchema: buildGroupedSchema(["create", "update", "delete"], {
57
- create: "Add attachment to card",
58
- update: "Update attachment",
59
- delete: "Delete attachment",
60
- }, {
61
- id: {
62
- description: "Card ID (for create) or Attachment ID (for update, delete)",
63
- requiredFor: ["create", "update", "delete"],
64
- },
65
- data: {
66
- description: "Attachment data: { type: 'file'|'link', name?: string, url?: string (for link) }. Binary file uploads are not supported by this MCP server; use type 'link'.",
67
- requiredFor: ["create", "update"],
68
- properties: {
69
- type: { type: "string", enum: ["file", "link"], description: "Attachment type", required: true },
70
- name: { type: "string", description: "Attachment name" },
71
- url: { type: "string", description: "URL (for link attachments)" },
72
- },
73
- },
74
- }),
75
- };
76
33
  /**
77
34
  * Board Members tool - manages board memberships
78
35
  */
@@ -14,6 +14,10 @@ export interface ToolOperation {
14
14
  requiresAuth?: boolean;
15
15
  /** Description of this specific operation */
16
16
  description?: string;
17
+ /** Send data as multipart/form-data instead of JSON (file uploads). */
18
+ requestType?: "multipart";
19
+ /** Fully custom handling in the engine (attachments download). */
20
+ custom?: "downloadAttachment";
17
21
  }
18
22
  /**
19
23
  * Grouped tool definition - multiple operations under one tool
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@denisixnpm/planka-mcp",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "MCP server for Planka - Real-Time Collaborative Kanban Board",
5
5
  "main": "dist/server.js",
6
6
  "type": "module",