@denisixnpm/planka-mcp 2.3.1 → 2.5.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
@@ -9,17 +9,16 @@ MCP server for [Planka](https://planka.app/) (real-time Kanban), tuned for AI co
9
9
  **Claude Code, Codex, opencode** and any MCP client. Works with Planka v2 and v1 (≤ 1.26.x).
10
10
  No build step, no Docker required — `npx` runs the prebuilt package over stdio.
11
11
 
12
- ## Install
12
+ **Auth:** `PLANKA_USERNAME`/`PLANKA_PASSWORD` works on **every** Planka version (login, token
13
+ cache, and silent re-login are automatic). `PLANKA_API_KEY` is **v2 only** — generate one in
14
+ Planka user settings → *API keys* (v1.26.x has none; `X-Api-Key` returns 401 there, verified).
15
+ All examples below use username/password; on v2 you may swap the two auth envs for
16
+ `PLANKA_API_KEY=your-api-key`.
13
17
 
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
+ ## Install
18
19
 
19
20
  ### Claude Code
20
21
 
21
- **Planka v1 (≤ 1.26.x) — username/password:**
22
-
23
22
  ```bash
24
23
  claude mcp add planka \
25
24
  --env PLANKA_BASE_URL=http://localhost:3000 \
@@ -28,16 +27,7 @@ claude mcp add planka \
28
27
  -- npx -y @denisixnpm/planka-mcp
29
28
  ```
30
29
 
31
- **Planka v2 API key (username/password above works too):**
32
-
33
- ```bash
34
- claude mcp add planka \
35
- --env PLANKA_BASE_URL=http://localhost:3000 \
36
- --env PLANKA_API_KEY=your-api-key \
37
- -- npx -y @denisixnpm/planka-mcp
38
- ```
39
-
40
- Or in `claude_desktop_config.json` / `.mcp.json` (username/password form — works on both versions):
30
+ Or in `claude_desktop_config.json` / `.mcp.json`:
41
31
 
42
32
  ```json
43
33
  {
@@ -53,8 +43,6 @@ Or in `claude_desktop_config.json` / `.mcp.json` (username/password form — wor
53
43
 
54
44
  ### Codex (`~/.codex/config.toml`)
55
45
 
56
- **Planka v1 (≤ 1.26.x):**
57
-
58
46
  ```toml
59
47
  [mcp_servers.planka]
60
48
  command = "npx"
@@ -62,19 +50,8 @@ args = ["-y", "@denisixnpm/planka-mcp"]
62
50
  env = { PLANKA_BASE_URL = "http://localhost:3000", PLANKA_USERNAME = "your-email-or-username", PLANKA_PASSWORD = "your-password" }
63
51
  ```
64
52
 
65
- **Planka v2 (API key):**
66
-
67
- ```toml
68
- [mcp_servers.planka]
69
- command = "npx"
70
- args = ["-y", "@denisixnpm/planka-mcp"]
71
- env = { PLANKA_BASE_URL = "http://localhost:3000", PLANKA_API_KEY = "your-api-key" }
72
- ```
73
-
74
53
  ### opencode (`opencode.json`)
75
54
 
76
- **Planka v1 (≤ 1.26.x):**
77
-
78
55
  ```json
79
56
  {
80
57
  "mcp": {
@@ -87,21 +64,44 @@ env = { PLANKA_BASE_URL = "http://localhost:3000", PLANKA_API_KEY = "your-api-ke
87
64
  }
88
65
  ```
89
66
 
90
- **Planka v2 (API key):**
67
+ Requires Node.js ≥ 22.19 (or Bun ≥ 1.3). Debug: `npx -y @denisixnpm/planka-mcp 2>&1 | tee debug.log`.
68
+
69
+ ## Attachments & files
70
+
71
+ `cards.get` returns the full working context in one call — description, tasks, task lists,
72
+ attachments, and the activity log (`actions`). Attachment records are normalized across versions
73
+ (v2's nested `data.url` flattened to `url`/`size`/`mimeType`).
74
+
75
+ - **Create with files**: `cards { action: "create", data: { name, attachments: [{ name?, filePath | text | contentBase64, mimeType? }] } }` — uploads happen in the same tool call. With `filePath` the server reads the local file itself and auto-detects name + MIME from the extension (.png, .jpg, .webp, .gif, .pdf, .doc(x), .xls(x), .ppt(x), .txt, .md, .csv, .json, and more).
76
+ - **Upload later**: `attachments { action: "upload", id: <cardId>, data: { name?, filePath | text | contentBase64 | url, mimeType? } }` — `url` creates a link attachment (v2 only).
77
+ - **Download**: `attachments { action: "download", id: <attachmentId>, data: { cardId } }` — images (`image/*`) come back as a rendered MCP image block, text files as decoded `text`, other binary as `contentBase64` (≤ 10 MB inline).
78
+ - **Rename / delete**: `attachments { action: "rename" | "delete", id, data: { name? } }`.
79
+
80
+ Downloads need username/password auth: Planka's file route authenticates via cookie only
81
+ (Bearer/API keys get 401 — verified on 1.26.2 and 2.0.3).
82
+
83
+ ## Agent workflow
84
+
85
+ **Minimal card creation** — `cards { action: "create", data: { name: "Fix login" } }` is enough:
86
+ `type` and `position` (append at end) are defaulted; add `description`, `dueDate`, `listId`
87
+ (move), and inline `attachments` as needed.
88
+
89
+ **Find without walking lists** — `cards { action: "find", data: { boardId?, name: "login" } }`
90
+ searches every card on the board case-insensitively and returns matches with their list names
91
+ (`boardId` can come from the `context` tool).
92
+
93
+ **Scoped context** — select a project/board/list/card once, then omit ids:
91
94
 
92
95
  ```json
93
- {
94
- "mcp": {
95
- "planka": {
96
- "type": "local",
97
- "command": ["npx", "-y", "@denisixnpm/planka-mcp"],
98
- "environment": { "PLANKA_BASE_URL": "http://localhost:3000", "PLANKA_API_KEY": "your-api-key" }
99
- }
100
- }
101
- }
96
+ { "action": "set", "data": { "boardId": "..." } }
102
97
  ```
103
98
 
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`.
99
+ `context.set` returns an overview of what you opened; setting a higher level resets deeper ones.
100
+ Scope is per SSE session, process-wide for stdio.
101
+
102
+ **Condensed output** — responses keep only agent-relevant fields (ids, names, positions, states);
103
+ timestamps, password hashes, and audit metadata are stripped, long text truncated, `included`
104
+ sidecars reduced per entity. Escape hatches: per-call `raw: true` or `PLANKA_CONDENSED_OUTPUT=false`.
105
105
 
106
106
  ## Configuration
107
107
 
@@ -114,28 +114,14 @@ Requires Node.js ≥ 22.19 (or run the same command with Bun ≥ 1.3). Debug: `n
114
114
  | `PLANKA_HTTP_MAX_RETRIES` | `2` | Retries for `408`/`429`/`5xx` and network errors |
115
115
  | `PLANKA_HTTP_RETRY_BASE_DELAY_MS` | `250` | Exponential backoff base (`base * 2^attempt`) |
116
116
  | `PLANKA_CONDENSED_OUTPUT` | `true` | Condensed agent output; `false` = raw payloads |
117
- | `ENABLE_ALL_TOOLS` | `false` | Enable all 28 tools (otherwise 11 core) |
117
+ | `ENABLE_ALL_TOOLS` | `false` | Enable all 28 tools (otherwise 12 core) |
118
118
  | `ENABLE_ADMIN_TOOLS` / `ENABLE_OPTIONAL_TOOLS` | `false` | Enable one extra category |
119
119
  | `MCP_TRANSPORT` | `stdio` | `stdio` (single client) or `sse` (multi-client) |
120
120
  | `MCP_PORT` / `MCP_HOST` | `3001` / `127.0.0.1` | SSE mode bind; `0.0.0.0` in Docker |
121
121
  | `MCP_AUTH_TOKEN` | — | If set, SSE endpoints require `Authorization: Bearer <token>` |
122
- | `MCP_CORS_ORIGIN` | — | If set, emitted as `Access-Control-Allow-Origin` (no CORS by default) |
123
122
  | `MCP_HEARTBEAT_INTERVAL_MS` | `30000` | SSE keep-alive interval for strict proxies |
124
123
 
125
- Note: Bun auto-loads `.env` from the working directory (real env vars win); the Node/npx path does not.
126
-
127
- ## Agent workflow
128
-
129
- **Scoped context** — select a project/board/list/card once, then omit ids:
130
-
131
- ```json
132
- {"name": "context", "arguments": {"action": "set", "data": {"boardId": "..."}}}
133
- {"name": "cards", "arguments": {"action": "create", "data": {"name": "Ship it", "type": "project", "position": 1}}}
134
- ```
135
-
136
- `context.set` returns an overview of what you opened (boards of the project, lists/cards of the board). Setting a higher level resets deeper ones. Scope is per SSE session; process-wide for stdio.
137
-
138
- **Condensed output** — responses keep only agent-relevant fields (ids, names, positions, states); timestamps, password hashes and audit metadata are stripped, long text truncated, `included` sidecars reduced per entity. Escape hatches: per-call `raw: true` or `PLANKA_CONDENSED_OUTPUT=false`.
124
+ Bun auto-loads `.env` from the working directory (real env vars win); the Node/npx path does not.
139
125
 
140
126
  ## Tools (28)
141
127
 
@@ -143,60 +129,58 @@ Every tool takes `action`, optional `id`/`data`/`query`, and optional `raw: true
143
129
 
144
130
  | Category | Tools |
145
131
  |----------|-------|
146
- | **Core** (11, always on) | `auth`, `bootstrap`, `context`, `projects`, `boards`, `lists`, `cards`, `comments`, `tasks`, `labels`, `cardMembers` |
147
- | **Admin** (4) | `config`, `users`, `webhooks`, `projectManagers` |
148
- | **Optional** (13) | `attachments`, `backgroundImages`, `boardMembers`, `cardExtras`, `cardMemberExtras`, `commentExtras`, `customFields`, `labelExtras`, `listExtras`, `notifications`, `taskExtras`, `userInfo`, `actions` |
132
+ | Core (12, always on) | `auth`, `bootstrap`, `context`, `projects`, `boards`, `lists`, `cards`, `attachments`, `comments`, `tasks`, `labels`, `cardMembers` |
133
+ | Admin (4) | `users`, `projectManagers`, `webhooks`, `notificationServices` |
134
+ | Optional (12) | `actions`, `boardMembers`, `customFields`, `notifications`, `backgroundImages`, `cardExtras`, `commentExtras`, `listExtras`, `taskExtras`, `labelExtras`, `cardMemberExtras`, `userInfo` |
149
135
 
150
- Actions per tool are enumerated in the MCP `tools/list` response. Live-verified semantics worth knowing: project managers exist only on `shared` projects; notification services are self-only; Planka v2 JSON link attachments HTTP 500 (multipart required); v1.26.2 has no REST comments/task-lists/custom-fields/webhooks (clean 404s), and renamed v1 membership/label routes are retried automatically.
136
+ Actions per tool are enumerated in the MCP `tools/list` response. Live-verified semantics worth
137
+ knowing: project managers exist only on `shared` projects; notification services are self-only;
138
+ v1.26.2 lacks REST task-lists/custom-fields/webhooks (clean 404s); renamed v1 membership/label
139
+ routes are retried automatically; link attachments to some URLs (e.g. `example.com`) HTTP 500
140
+ inside Planka 2.0.3 itself.
151
141
 
152
142
  ## Multi-client SSE mode (optional)
153
143
 
154
- To serve several clients over HTTP instead of stdio, run the same package in SSE mode:
155
-
156
144
  ```bash
157
- MCP_TRANSPORT=sse MCP_HOST=127.0.0.1 MCP_PORT=3001 MCP_AUTH_TOKEN=change-me \
158
- PLANKA_BASE_URL=http://localhost:3000 PLANKA_API_KEY=your-api-key \
159
- npx -y @denisixnpm/planka-mcp
145
+ MCP_TRANSPORT=sse npx -y @denisixnpm/planka-mcp
160
146
  ```
161
147
 
162
148
  Connect MCP clients to `http://127.0.0.1:3001/sse`. Set `MCP_AUTH_TOKEN` whenever binding beyond
163
- loopback (`MCP_HOST=0.0.0.0`) — without it, anyone who can reach the port has full tool access.
164
- A `--healthcheck` mode is built in for orchestrators.
165
-
166
- Prefer a container? The image is published on Docker Hub:
167
- [`hub.docker.com/r/denisix/planka-mcp`](https://hub.docker.com/r/denisix/planka-mcp)
149
+ loopback — without it, anyone who can reach the port has full tool access. A `--healthcheck`
150
+ mode is built in for orchestrators.
168
151
 
152
+ Prefer a container? [`hub.docker.com/r/denisix/planka-mcp`](https://hub.docker.com/r/denisix/planka-mcp):
169
153
 
170
154
  ```bash
171
- docker run -d -p 3001:3001 \
172
- -e MCP_TRANSPORT=sse -e MCP_HOST=0.0.0.0 -e MCP_AUTH_TOKEN=change-me \
155
+ docker run -d --name planka-mcp -p 3001:3001 \
173
156
  -e PLANKA_BASE_URL=http://host.docker.internal:3000 \
174
- -e PLANKA_API_KEY=your-api-key \
175
- denisix/planka-mcp:latest
157
+ -e PLANKA_USERNAME -e PLANKA_PASSWORD \
158
+ denisix/planka-mcp
176
159
  ```
177
160
 
178
- Compiled Bun binary on distroless (171 MB, no shell, no node_modules).
161
+ Compiled Bun binary on distroless (no shell, no node_modules).
179
162
 
180
163
  ## Development
181
164
 
182
165
  ```bash
183
- bun install && bun test # build + full hermetic suite
184
- bun run dev # run from source (stdio)
166
+ bun install
167
+ bun test # hermetic suite
168
+ E2E_PLANKA_URL=… E2E_PLANKA_V1_URL=… bun test # + live Planka e2e (154 tests)
185
169
  ```
186
170
 
187
- End-to-end tests run against real Planka stacks (`docker compose --profile e2e up -d`, then `E2E_PLANKA_URL=... bun test`). See [CONTRIBUTING.md](CONTRIBUTING.md).
171
+ E2E stacks: `docker compose --profile e2e up -d`. Details in [CONTRIBUTING.md](CONTRIBUTING.md)
172
+ and [CLAUDE.md](CLAUDE.md).
188
173
 
189
174
  ## Credits
190
175
 
191
176
  Fork of [planka-mcp](https://github.com/chmald/planka-mcp) by [Christopher Maldonado](https://github.com/chmald) — credit for the original server goes to him. Maintained by [denisix](https://github.com/denisix), published as [`@denisixnpm/planka-mcp`](https://www.npmjs.com/package/@denisixnpm/planka-mcp).
192
177
 
193
- **What this fork improves**
194
-
195
- - **Security**: 0 `npm audit` findings; loopback-only SSE by default, timing-safe bearer auth, no wildcard CORS, validated login responses (no `Bearer undefined`), single-flight login, per-request timeouts.
196
- - **Planka v1 compatibility**: renamed v1 routes retried transparently on 404 — one build serves v2 and v1.
197
- - **Agent-optimized**: condensed output + `context` tool for scoped, id-free workflows.
198
- - **Lean**: one production dependency (MCP SDK), built-in `fetch`, compiled distroless image.
199
- - **Verified**: 117 tests incl. live E2E against real Planka 2.0.3 and 1.26.2, SSE protocol round-trips, engine edge cases.
178
+ **What this fork improves**: security hardening (0 `npm audit` findings, loopback-only SSE,
179
+ timing-safe auth, single-flight login, per-request timeouts); Planka v1 compatibility with
180
+ automatic legacy-route fallbacks; condensed agent output and scoped working context; full
181
+ attachment support (upload/download/create-with-files, both Planka majors); agent-first card
182
+ workflows (minimal create, board-wide find); npm + Docker Hub release pipelines with provenance
183
+ and SBOM.
200
184
 
201
185
  ## License
202
186
 
package/dist/condense.js CHANGED
@@ -21,7 +21,7 @@ const ENTITY_FIELDS = {
21
21
  boards: ["id", "name", "position"],
22
22
  lists: ["id", "name", "type", "position"],
23
23
  listExtras: ["id", "name", "type", "position"],
24
- cards: ["id", "name", "description", "position", "listId", "isArchived"],
24
+ cards: ["id", "name", "description", "position", "listId", "listName", "isArchived"],
25
25
  cardExtras: ["id", "name", "description", "position", "listId", "isArchived"],
26
26
  tasks: ["id", "name", "isCompleted", "taskListId"],
27
27
  taskExtras: ["id", "name", "isCompleted", "taskListId"],
@@ -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,13 @@ export function condenseResult(toolName, data) {
103
103
  }
104
104
  if (typeof data === "object" && data !== null) {
105
105
  const source = data;
106
+ // Attachment download payloads carry base64 / decoded text — whitelists
107
+ // and text truncation must not touch them (size is capped by the engine).
108
+ if (typeof source.contentBase64 === "string" ||
109
+ typeof source.imageBase64 === "string" ||
110
+ (toolName === "attachments" && typeof source.text === "string" && source.size !== undefined)) {
111
+ return data;
112
+ }
106
113
  const hasItems = Array.isArray(source.items);
107
114
  const hasItem = "item" in source;
108
115
  if (hasItems || hasItem) {
package/dist/server.js CHANGED
@@ -5,10 +5,30 @@ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
5
5
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
6
6
  import { createServer } from "node:http";
7
7
  import { createHash, timingSafeEqual } from "node:crypto";
8
+ import { readFile, stat as statFile } from "node:fs/promises";
9
+ import { basename, extname } from "node:path";
8
10
  // Import tool definitions
9
11
  import { getEnabledTools, toolCounts } from "./tools/index.js";
10
12
  import { condenseResult } from "./condense.js";
11
- // ----- Configuration -----
13
+ /** Common file extensions -> MIME type (uploads via data.filePath). */
14
+ const MIME_BY_EXT = {
15
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
16
+ ".webp": "image/webp", ".svg": "image/svg+xml", ".avif": "image/avif", ".bmp": "image/bmp", ".ico": "image/x-icon",
17
+ ".pdf": "application/pdf",
18
+ ".doc": "application/msword",
19
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
20
+ ".xls": "application/vnd.ms-excel",
21
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
22
+ ".ppt": "application/vnd.ms-powerpoint",
23
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
24
+ ".odt": "application/vnd.oasis.opendocument.text",
25
+ ".txt": "text/plain", ".md": "text/markdown", ".csv": "text/csv", ".html": "text/html", ".xml": "text/xml",
26
+ ".json": "application/json", ".yaml": "application/yaml", ".yml": "application/yaml",
27
+ ".zip": "application/zip", ".gz": "application/gzip", ".tar": "application/x-tar",
28
+ ".mp3": "audio/mpeg", ".wav": "audio/wav", ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
29
+ };
30
+ /** Hard cap for attachment bytes moved through the server (upload and download). */
31
+ const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
12
32
  const PLANKA_BASE_URL = process.env.PLANKA_BASE_URL || "http://localhost:3000";
13
33
  const PLANKA_USERNAME = process.env.PLANKA_USERNAME;
14
34
  const PLANKA_PASSWORD = process.env.PLANKA_PASSWORD;
@@ -138,6 +158,14 @@ async function executeGroupedApiCall(groupedDef, input, overridePath, retryAttem
138
158
  const validActions = Object.keys(groupedDef.operations).join(", ");
139
159
  return { success: false, error: `Invalid action '${action}'. Valid actions: ${validActions}` };
140
160
  }
161
+ // Fully custom operations (binary/cookie auth and board-wide search that
162
+ // the generic JSON engine cannot express).
163
+ if (operation.custom === "downloadAttachment") {
164
+ return downloadAttachment(input, scope);
165
+ }
166
+ if (operation.custom === "findCards") {
167
+ return findCards(input, scope);
168
+ }
141
169
  // Construct URL with path parameters
142
170
  const canonicalPath = overridePath ?? operation.path;
143
171
  let actualPath = canonicalPath;
@@ -201,7 +229,56 @@ async function executeGroupedApiCall(groupedDef, input, overridePath, retryAttem
201
229
  }
202
230
  // Handle request body
203
231
  let body = undefined;
204
- if (["POST", "PUT", "PATCH"].includes(methodUpper) && input?.data !== undefined) {
232
+ if (operation.requestType === "multipart") {
233
+ // Planka file upload: v2 requires form fields `type` + `name`; v1
234
+ // ignores extra fields, so one v2-shaped request serves both majors.
235
+ const d = (input?.data ?? {});
236
+ const isLink = typeof d.url === "string" && d.url !== "" && d.contentBase64 === undefined && d.text === undefined && d.filePath === undefined;
237
+ const fd = new FormData();
238
+ fd.append("type", isLink ? "link" : "file");
239
+ if (isLink) {
240
+ fd.append("name", typeof d.name === "string" && d.name !== "" ? d.name : "link");
241
+ fd.append("url", String(d.url));
242
+ }
243
+ else {
244
+ let bytes;
245
+ let name = typeof d.name === "string" && d.name !== "" ? d.name : "";
246
+ let mimeType = typeof d.mimeType === "string" ? d.mimeType : "";
247
+ if (typeof d.filePath === "string" && d.filePath !== "") {
248
+ // Agents hand over a local file path; the server reads and encodes it.
249
+ if (!name)
250
+ name = basename(d.filePath);
251
+ try {
252
+ const fileInfo = await statFile(d.filePath);
253
+ if (fileInfo.size > MAX_ATTACHMENT_BYTES) {
254
+ return { success: false, error: `File too large (${fileInfo.size} bytes > ${MAX_ATTACHMENT_BYTES}): ${d.filePath}` };
255
+ }
256
+ bytes = new Uint8Array(await readFile(d.filePath));
257
+ }
258
+ catch (readError) {
259
+ return { success: false, error: `Cannot read data.filePath (${d.filePath}): ${readError instanceof Error ? readError.message : String(readError)}` };
260
+ }
261
+ if (!mimeType)
262
+ mimeType = MIME_BY_EXT[extname(d.filePath).toLowerCase()] ?? "";
263
+ }
264
+ else if (typeof d.contentBase64 === "string" && d.contentBase64 !== "") {
265
+ const binary = atob(d.contentBase64);
266
+ bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0));
267
+ }
268
+ else if (typeof d.text === "string") {
269
+ bytes = new TextEncoder().encode(d.text);
270
+ if (!mimeType)
271
+ mimeType = "text/plain";
272
+ }
273
+ else {
274
+ return { success: false, error: "Provide data.filePath, data.contentBase64, data.text, or data.url for the attachment content" };
275
+ }
276
+ fd.append("name", name !== "" ? name : "attachment");
277
+ fd.append("file", new Blob([bytes], { type: mimeType !== "" ? mimeType : "application/octet-stream" }), name !== "" ? name : "attachment");
278
+ }
279
+ body = fd;
280
+ }
281
+ else if (["POST", "PUT", "PATCH"].includes(methodUpper) && input?.data !== undefined) {
205
282
  headers["Content-Type"] = "application/json";
206
283
  body = JSON.stringify(input.data);
207
284
  }
@@ -272,6 +349,213 @@ async function executeGroupedApiCall(groupedDef, input, overridePath, retryAttem
272
349
  };
273
350
  }
274
351
  }
352
+ /**
353
+ * Planka v2 nests attachment metadata under `data` ({url, size, mimeType});
354
+ * v1 keeps `url` top-level. Flatten to one shape for agents and the condenser.
355
+ */
356
+ function normalizeAttachment(raw) {
357
+ const item = (raw ?? {});
358
+ const nested = item.data;
359
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) {
360
+ return {
361
+ ...item,
362
+ url: item.url ?? nested.url,
363
+ size: nested.size,
364
+ mimeType: nested.mimeType,
365
+ data: undefined,
366
+ };
367
+ }
368
+ return item;
369
+ }
370
+ /** Inline decoded text for downloads up to this size; larger stays base64-only. */
371
+ const MAX_INLINE_TEXT_BYTES = 64 * 1024;
372
+ /** Normalize every attachment record inside a `{item|items, included}` envelope. */
373
+ function normalizeAttachmentsIn(data) {
374
+ if (typeof data !== "object" || data === null)
375
+ return;
376
+ const envelope = data;
377
+ const item = envelope.item;
378
+ if (item && item.type !== undefined && (item.cardId !== undefined || item.data !== undefined)) {
379
+ envelope.item = normalizeAttachment(item);
380
+ }
381
+ const list = envelope.included?.attachments;
382
+ if (Array.isArray(list)) {
383
+ envelope.included.attachments = list.map(normalizeAttachment);
384
+ }
385
+ }
386
+ /**
387
+ * Download an attachment's bytes. Planka serves files from
388
+ * /attachments/{id}/download/{name}, which authenticates via the
389
+ * `accessToken` cookie only — Bearer and X-Api-Key headers get 401 (verified
390
+ * against live 1.26.2 and 2.0.3). The attachment record must be located on
391
+ * its owning card first, since there is no GET /attachments/{id}.
392
+ */
393
+ /**
394
+ * Board-wide card search: agents say "find the card called X" without
395
+ * walking lists. One board GET supplies cards + lists on both Planka majors;
396
+ * results are matched case-insensitively on a name substring and annotated
397
+ * with the containing list's name.
398
+ */
399
+ async function findCards(input, scope) {
400
+ const boardId = String(input?.data?.boardId ?? input?.id ?? scope?.boardId ?? "");
401
+ const needle = String(input?.data?.name ?? input?.query?.search ?? "").toLowerCase();
402
+ if (!boardId) {
403
+ return { success: false, error: "Provide data.boardId (or id), or select a board via the context tool" };
404
+ }
405
+ if (!needle) {
406
+ return { success: false, error: "Provide data.name (case-insensitive substring) to search for" };
407
+ }
408
+ const boardsTool = toolMap.get("boards");
409
+ if (!boardsTool) {
410
+ return { success: false, error: "boards tool unavailable; cannot search" };
411
+ }
412
+ const boardRes = await executeGroupedApiCall(boardsTool.groupedDef, { action: "get", id: boardId });
413
+ if (!boardRes.success) {
414
+ return boardRes;
415
+ }
416
+ const included = (boardRes.data?.included ?? {});
417
+ const listNames = new Map((included.lists ?? []).map(l => [String(l.id), String(l.name ?? "")]));
418
+ const matches = (included.cards ?? [])
419
+ .filter(card => String(card.name ?? "").toLowerCase().includes(needle))
420
+ .map(card => ({
421
+ id: card.id,
422
+ name: card.name,
423
+ listId: card.listId,
424
+ listName: listNames.get(String(card.listId)) ?? "",
425
+ position: card.position,
426
+ isArchived: card.isArchived,
427
+ }));
428
+ return {
429
+ success: true,
430
+ data: {
431
+ items: matches,
432
+ query: { boardId, name: needle },
433
+ count: matches.length,
434
+ },
435
+ };
436
+ }
437
+ async function downloadAttachment(input, scope) {
438
+ if (PLANKA_API_KEY) {
439
+ return { success: false, error: "Attachment downloads require PLANKA_USERNAME/PLANKA_PASSWORD auth: Planka serves files from a cookie-authenticated route that rejects API keys" };
440
+ }
441
+ const attachmentId = String(input?.id ?? "");
442
+ const cardId = String(input?.data?.cardId ?? scope?.cardId ?? "");
443
+ if (!attachmentId || !cardId) {
444
+ return { success: false, error: "Provide id (attachment ID) and data.cardId (owning card), or select the card via the context tool" };
445
+ }
446
+ const cardsTool = toolMap.get("cards");
447
+ if (!cardsTool) {
448
+ return { success: false, error: "cards tool unavailable; cannot resolve attachment" };
449
+ }
450
+ const cardRes = await executeGroupedApiCall(cardsTool.groupedDef, { action: "get", id: cardId });
451
+ if (!cardRes.success) {
452
+ return cardRes;
453
+ }
454
+ const found = (cardRes.data?.included?.attachments ?? []).find(a => String(a.id) === attachmentId);
455
+ if (!found) {
456
+ return { success: false, error: `Attachment ${attachmentId} not found on card ${cardId}` };
457
+ }
458
+ const record = normalizeAttachment(found);
459
+ if (record.type === "link" || typeof record.url !== "string" || record.url === "") {
460
+ return { success: true, data: { ...record, note: "link attachment — follow data.url" } };
461
+ }
462
+ const target = new URL(String(record.url), PLANKA_BASE_URL);
463
+ const token = await getAccessToken();
464
+ let res;
465
+ try {
466
+ res = await fetch(target, {
467
+ headers: { Authorization: `Bearer ${token}`, cookie: `accessToken=${token}` },
468
+ signal: AbortSignal.timeout(PLANKA_HTTP_TIMEOUT_MS),
469
+ });
470
+ }
471
+ catch (downloadError) {
472
+ return { success: false, error: `Attachment download failed: ${downloadError instanceof Error ? downloadError.message : String(downloadError)}` };
473
+ }
474
+ if (!res.ok) {
475
+ return { success: false, error: `Attachment download failed: HTTP ${res.status}` };
476
+ }
477
+ const bytes = new Uint8Array(await res.arrayBuffer());
478
+ if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
479
+ return { success: false, error: `Attachment too large to inline (${bytes.byteLength} bytes > ${MAX_ATTACHMENT_BYTES}); fetch it directly: ${target}` };
480
+ }
481
+ const mimeType = res.headers.get("content-type")?.split(";")[0] ?? record.mimeType ?? "application/octet-stream";
482
+ const base64 = Buffer.from(bytes).toString("base64");
483
+ const payload = {
484
+ id: attachmentId,
485
+ name: record.name,
486
+ size: bytes.byteLength,
487
+ mimeType,
488
+ };
489
+ if (mimeType.startsWith("image/")) {
490
+ // Rendered directly by MCP clients as an image content block; the raw
491
+ // base64 stays available for agents that need the bytes.
492
+ payload.imageBase64 = base64;
493
+ }
494
+ else if (mimeType.startsWith("text/") || mimeType === "application/json" || mimeType === "application/yaml") {
495
+ const decoded = Buffer.from(bytes).toString("utf8");
496
+ if (bytes.byteLength <= MAX_INLINE_TEXT_BYTES && !decoded.includes("\u0000")) {
497
+ payload.text = decoded;
498
+ }
499
+ else {
500
+ payload.contentBase64 = base64;
501
+ }
502
+ }
503
+ else {
504
+ payload.contentBase64 = base64;
505
+ }
506
+ return { success: true, data: payload };
507
+ }
508
+ /**
509
+ * Post-processing for card calls:
510
+ * - `get`: fold the activity log (`actions`) into `included` so one call
511
+ * returns the full working context — description, tasks, attachments,
512
+ * actions. Best-effort: an actions failure never fails the card get.
513
+ * - `create`: upload any `data.attachments` entries and fold them into
514
+ * `included.attachments`.
515
+ */
516
+ async function enrichCardResult(action, data, pendingAttachments, cardId, scope) {
517
+ normalizeAttachmentsIn(data);
518
+ const cardsTool = toolMap.get("cards");
519
+ if (action === "get" && cardsTool && cardId) {
520
+ try {
521
+ const acts = await executeGroupedApiCall(cardsTool.groupedDef, { action: "getActions", id: cardId }, undefined, 0, false, false, scope);
522
+ if (acts.success && Array.isArray(acts.data?.items)) {
523
+ if (!data.included || typeof data.included !== "object")
524
+ data.included = {};
525
+ data.included.actions = acts.data.items;
526
+ }
527
+ }
528
+ catch (actionsError) {
529
+ console.error(`[cards.get] actions fetch failed for card ${cardId}: ${actionsError instanceof Error ? actionsError.message : String(actionsError)}`);
530
+ }
531
+ }
532
+ if (action === "create" && pendingAttachments && pendingAttachments.length > 0) {
533
+ const createdCardId = String(data?.item?.id ?? cardId ?? "");
534
+ const attachmentsTool = toolMap.get("attachments");
535
+ if (createdCardId && attachmentsTool) {
536
+ const uploaded = [];
537
+ const errors = [];
538
+ for (const att of pendingAttachments) {
539
+ const up = await executeGroupedApiCall(attachmentsTool.groupedDef, { action: "upload", id: createdCardId, data: att });
540
+ if (up.success) {
541
+ uploaded.push(normalizeAttachment(up.data?.item));
542
+ }
543
+ else {
544
+ errors.push({ name: String(att?.name ?? "attachment"), error: up.error ?? "upload failed" });
545
+ }
546
+ }
547
+ if (uploaded.length > 0) {
548
+ if (!data.included || typeof data.included !== "object")
549
+ data.included = {};
550
+ data.included.attachments = [...(data.included.attachments ?? []), ...uploaded];
551
+ }
552
+ if (errors.length > 0) {
553
+ data.attachmentUploadErrors = errors;
554
+ }
555
+ }
556
+ }
557
+ return data;
558
+ }
275
559
  /**
276
560
  * Local handler for the `context` tool: maintains the working scope of this
277
561
  * server instance (per SSE session, process-wide for stdio). Enriches `set`
@@ -342,7 +626,7 @@ if (!process.argv.includes("--healthcheck")) {
342
626
  function createMcpServer() {
343
627
  const server = new McpServer({
344
628
  name: "planka-mcp",
345
- version: "2.3.1",
629
+ version: "2.5.0",
346
630
  }, {
347
631
  capabilities: {
348
632
  tools: {},
@@ -366,12 +650,47 @@ function createMcpServer() {
366
650
  isError: true,
367
651
  };
368
652
  }
653
+ // cards.create convenience: Planka v2 requires type + position — default
654
+ // them instead of failing (v1 ignores both extras). data.attachments is
655
+ // stripped from the API payload and uploaded after the card exists.
656
+ let pendingAttachments = null;
657
+ const reqArgs = (args ?? {});
658
+ if (toolDef.groupedDef.name === "cards" && reqArgs.action === "create") {
659
+ const data = (reqArgs.data ?? {});
660
+ if (data.type === undefined)
661
+ data.type = "story";
662
+ if (data.position === undefined)
663
+ data.position = 65535;
664
+ if (Array.isArray(data.attachments)) {
665
+ pendingAttachments = data.attachments;
666
+ const { attachments: _omit, ...dataWithout } = data;
667
+ reqArgs.data = dataWithout;
668
+ }
669
+ }
369
670
  const result = toolDef.groupedDef.name === "context"
370
671
  ? await handleContextCall(args, scope)
371
672
  : await executeGroupedApiCall(toolDef.groupedDef, args, undefined, 0, false, true, scope);
673
+ if (result.success && toolDef.groupedDef.name === "attachments") {
674
+ normalizeAttachmentsIn(result.data);
675
+ }
676
+ if (result.success && toolDef.groupedDef.name === "cards") {
677
+ result.data = await enrichCardResult(String(args?.action ?? ""), result.data, pendingAttachments, typeof args?.id === "string" ? args.id : undefined, scope);
678
+ }
372
679
  if (result.success) {
373
680
  const wantsRaw = !PLANKA_CONDENSED_OUTPUT || args?.raw === true;
374
681
  const payload = wantsRaw ? result.data : condenseResult(toolDef.groupedDef.name, result.data);
682
+ const asRecord = (typeof payload === "object" && payload !== null ? payload : undefined);
683
+ // Image downloads additionally ship an MCP image content block so
684
+ // clients render the picture directly instead of showing base64.
685
+ if (asRecord && typeof asRecord.imageBase64 === "string" && reqArgs.action === "download") {
686
+ const { imageBase64, ...meta } = asRecord;
687
+ return {
688
+ content: [
689
+ { type: "image", data: imageBase64, mimeType: String(asRecord.mimeType ?? "image/png") },
690
+ { type: "text", text: JSON.stringify(meta, null, 2) },
691
+ ],
692
+ };
693
+ }
375
694
  return {
376
695
  content: [
377
696
  {
@@ -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,19 @@ 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
+ * - upload: POST /cards/{cardId}/attachments, multipart. v2 requires form
26
+ * fields `type` ("file"|"link") and `name`; v1 ignores extra fields, so a
27
+ * single v2-shaped request works on both.
28
+ * - download: no JSON API — the attachment record carries an `url`
29
+ * (/attachments/{id}/download/{name}) that Planka authenticates via the
30
+ * `accessToken` cookie only (Bearer/X-Api-Key get 401). The engine fetches
31
+ * it and returns base64.
32
+ * - link attachments (url instead of file content) are Planka v2 only.
33
+ */
34
+ export declare const attachmentsTool: GroupedToolDefinition;
22
35
  /**
23
36
  * Comments tool - manages comments on cards
24
37
  */
@@ -231,12 +231,23 @@ 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",
238
243
  path: "/lists/{listId}/cards",
239
- description: "Create a new card in a list",
244
+ description: "Create a new card in a list (type/position default automatically; accepts inline attachments)",
245
+ },
246
+ find: {
247
+ method: "GET",
248
+ path: "/cards/{id}",
249
+ custom: "findCards",
250
+ description: "Find cards by name across a whole board (no need to walk lists)",
240
251
  },
241
252
  update: {
242
253
  method: "PATCH",
@@ -249,28 +260,32 @@ export const cardsTool = {
249
260
  description: "Delete a card permanently",
250
261
  },
251
262
  },
252
- inputSchema: buildGroupedSchema(["list", "get", "create", "update", "delete"], {
263
+ inputSchema: buildGroupedSchema(["list", "get", "getActions", "create", "find", "update", "delete"], {
253
264
  list: "Get cards from a list (requires listId)",
254
- get: "Get card details by ID",
265
+ get: "Get card details by ID (full context: description, tasks, attachments, actions)",
266
+ getActions: "Get card activity log",
255
267
  create: "Create a new card",
268
+ find: "Find cards by name across a board",
256
269
  update: "Update card properties",
257
270
  delete: "Delete a card",
258
271
  }, {
259
272
  id: {
260
- description: "Card ID (for get, update, delete) or List ID (for list, create)",
261
- requiredFor: ["list", "get", "update", "delete"],
273
+ description: "Card ID (get/getActions/update/delete), List ID (list/create), or Board ID (find)",
274
+ requiredFor: ["list", "get", "getActions", "create", "find", "update", "delete"],
262
275
  },
263
276
  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 }",
265
- requiredFor: ["create", "update"],
277
+ description: "Card data: create needs { name } only — type ('story') and position (append at end) are defaulted; optional: description, dueDate, isDueCompleted, listId (move), attachments: [{ name, text | contentBase64 | filePath, mimeType? }]. find: { name: substring, boardId? }. update: any of the above except attachments.",
278
+ requiredFor: ["create", "find", "update"],
266
279
  properties: {
267
- name: { type: "string", description: "Card name", required: true },
268
- type: { type: "string", enum: ["project", "story"], description: "Card type", required: true },
269
- position: { type: "number", description: "Card position (Planka requires this on create)", required: true },
280
+ name: { type: "string", description: "Card name (create) or case-insensitive substring to find (find)", required: true },
281
+ type: { type: "string", enum: ["project", "story"], description: "Card type (v2; defaults to 'story')" },
282
+ position: { type: "number", description: "Card position; defaults to append at the end of the list" },
270
283
  listId: { type: "string", description: "List ID (for create/move)" },
271
- description: { type: "string", description: "Card description" },
284
+ boardId: { type: "string", description: "Board to search (find; or set via context)" },
285
+ description: { type: "string", description: "Card description (markdown)" },
272
286
  dueDate: { type: "string", description: "Due date (ISO 8601)" },
273
287
  isDueCompleted: { type: "boolean", description: "Whether the due date is completed" },
288
+ attachments: { type: "array", description: "Files to attach on create: [{ name, filePath (server reads + encodes it) | contentBase64 | text, mimeType? }] (mimeType auto-detected from extension for filePath)" },
274
289
  },
275
290
  },
276
291
  query: {
@@ -280,6 +295,69 @@ export const cardsTool = {
280
295
  },
281
296
  }),
282
297
  };
298
+ /**
299
+ * Attachments tool - upload, download, and manage card file attachments.
300
+ *
301
+ * - upload: POST /cards/{cardId}/attachments, multipart. v2 requires form
302
+ * fields `type` ("file"|"link") and `name`; v1 ignores extra fields, so a
303
+ * single v2-shaped request works on both.
304
+ * - download: no JSON API — the attachment record carries an `url`
305
+ * (/attachments/{id}/download/{name}) that Planka authenticates via the
306
+ * `accessToken` cookie only (Bearer/X-Api-Key get 401). The engine fetches
307
+ * it and returns base64.
308
+ * - link attachments (url instead of file content) are Planka v2 only.
309
+ */
310
+ export const attachmentsTool = {
311
+ name: "attachments",
312
+ description: "Upload, download, and manage file attachments on Planka cards. Content moves as base64 (data.contentBase64 for upload, result.contentBase64 for download).",
313
+ operations: {
314
+ upload: {
315
+ method: "POST",
316
+ path: "/cards/{cardId}/attachments",
317
+ requestType: "multipart",
318
+ description: "Upload a file (contentBase64 or text) or attach a link (url, Planka v2) to a card",
319
+ },
320
+ download: {
321
+ method: "GET",
322
+ path: "/attachments/{id}",
323
+ custom: "downloadAttachment",
324
+ description: "Download an attachment's content as base64 (requires the owning card ID)",
325
+ },
326
+ rename: {
327
+ method: "PATCH",
328
+ path: "/attachments/{id}",
329
+ description: "Rename an attachment",
330
+ },
331
+ delete: {
332
+ method: "DELETE",
333
+ path: "/attachments/{id}",
334
+ description: "Delete an attachment permanently",
335
+ },
336
+ },
337
+ inputSchema: buildGroupedSchema(["upload", "download", "rename", "delete"], {
338
+ upload: "Upload a file or link to a card (id = card ID)",
339
+ download: "Download attachment content as base64 (id = attachment ID)",
340
+ rename: "Rename an attachment",
341
+ delete: "Delete an attachment",
342
+ }, {
343
+ id: {
344
+ description: "Card ID (for upload) or Attachment ID (for download, rename, delete)",
345
+ requiredFor: ["upload", "download", "rename", "delete"],
346
+ },
347
+ data: {
348
+ description: "Attachment data: upload = { name (required), contentBase64 | text | url, mimeType? }; download = { cardId? (owning card, or set via context) }; rename = { name }",
349
+ requiredFor: ["upload", "rename"],
350
+ properties: {
351
+ name: { type: "string", description: "File/attachment name (e.g. report.pdf)", required: true },
352
+ contentBase64: { type: "string", description: "Base64-encoded file content (preferred for binary)" },
353
+ text: { type: "string", description: "Plain-text content (alternative to contentBase64)" },
354
+ mimeType: { type: "string", description: "MIME type (e.g. application/pdf); default application/octet-stream, text/plain for text" },
355
+ url: { type: "string", description: "Link attachments (Planka v2): attach an external URL instead of file content" },
356
+ cardId: { type: "string", description: "Owning card ID for download (or set cardId via the context tool)" },
357
+ },
358
+ },
359
+ }),
360
+ };
283
361
  /**
284
362
  * Comments tool - manages comments on cards
285
363
  */
@@ -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 (attachment download, card search). */
20
+ custom?: "downloadAttachment" | "findCards";
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.1",
3
+ "version": "2.5.0",
4
4
  "description": "MCP server for Planka - Real-Time Collaborative Kanban Board",
5
5
  "main": "dist/server.js",
6
6
  "type": "module",