@denisixnpm/planka-mcp 2.4.0 → 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,37 +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`.
91
68
 
92
- ```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
- }
102
- ```
69
+ ## Attachments & files
103
70
 
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`.
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`).
105
74
 
106
- ## Attachments
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? } }`.
107
79
 
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`.
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).
112
82
 
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? } }`.
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:
94
+
95
+ ```json
96
+ { "action": "set", "data": { "boardId": "..." } }
97
+ ```
117
98
 
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).
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`.
121
105
 
122
106
  ## Configuration
123
107
 
@@ -135,23 +119,9 @@ and 2.0.3).
135
119
  | `MCP_TRANSPORT` | `stdio` | `stdio` (single client) or `sse` (multi-client) |
136
120
  | `MCP_PORT` / `MCP_HOST` | `3001` / `127.0.0.1` | SSE mode bind; `0.0.0.0` in Docker |
137
121
  | `MCP_AUTH_TOKEN` | — | If set, SSE endpoints require `Authorization: Bearer <token>` |
138
- | `MCP_CORS_ORIGIN` | — | If set, emitted as `Access-Control-Allow-Origin` (no CORS by default) |
139
122
  | `MCP_HEARTBEAT_INTERVAL_MS` | `30000` | SSE keep-alive interval for strict proxies |
140
123
 
141
- Note: Bun auto-loads `.env` from the working directory (real env vars win); the Node/npx path does not.
142
-
143
- ## Agent workflow
144
-
145
- **Scoped context** — select a project/board/list/card once, then omit ids:
146
-
147
- ```json
148
- {"name": "context", "arguments": {"action": "set", "data": {"boardId": "..."}}}
149
- {"name": "cards", "arguments": {"action": "create", "data": {"name": "Ship it", "type": "project", "position": 1}}}
150
- ```
151
-
152
- `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.
153
-
154
- **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.
155
125
 
156
126
  ## Tools (28)
157
127
 
@@ -159,60 +129,58 @@ Every tool takes `action`, optional `id`/`data`/`query`, and optional `raw: true
159
129
 
160
130
  | Category | Tools |
161
131
  |----------|-------|
162
- | **Core** (11, always on) | `auth`, `bootstrap`, `context`, `projects`, `boards`, `lists`, `cards`, `comments`, `tasks`, `labels`, `cardMembers` |
163
- | **Admin** (4) | `config`, `users`, `webhooks`, `projectManagers` |
164
- | **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` |
165
135
 
166
- 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.
167
141
 
168
142
  ## Multi-client SSE mode (optional)
169
143
 
170
- To serve several clients over HTTP instead of stdio, run the same package in SSE mode:
171
-
172
144
  ```bash
173
- MCP_TRANSPORT=sse MCP_HOST=127.0.0.1 MCP_PORT=3001 MCP_AUTH_TOKEN=change-me \
174
- PLANKA_BASE_URL=http://localhost:3000 PLANKA_API_KEY=your-api-key \
175
- npx -y @denisixnpm/planka-mcp
145
+ MCP_TRANSPORT=sse npx -y @denisixnpm/planka-mcp
176
146
  ```
177
147
 
178
148
  Connect MCP clients to `http://127.0.0.1:3001/sse`. Set `MCP_AUTH_TOKEN` whenever binding beyond
179
- loopback (`MCP_HOST=0.0.0.0`) — without it, anyone who can reach the port has full tool access.
180
- A `--healthcheck` mode is built in for orchestrators.
181
-
182
- Prefer a container? The image is published on Docker Hub:
183
- [`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.
184
151
 
152
+ Prefer a container? [`hub.docker.com/r/denisix/planka-mcp`](https://hub.docker.com/r/denisix/planka-mcp):
185
153
 
186
154
  ```bash
187
- docker run -d -p 3001:3001 \
188
- -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 \
189
156
  -e PLANKA_BASE_URL=http://host.docker.internal:3000 \
190
- -e PLANKA_API_KEY=your-api-key \
191
- denisix/planka-mcp:latest
157
+ -e PLANKA_USERNAME -e PLANKA_PASSWORD \
158
+ denisix/planka-mcp
192
159
  ```
193
160
 
194
- Compiled Bun binary on distroless (171 MB, no shell, no node_modules).
161
+ Compiled Bun binary on distroless (no shell, no node_modules).
195
162
 
196
163
  ## Development
197
164
 
198
165
  ```bash
199
- bun install && bun test # build + full hermetic suite
200
- 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)
201
169
  ```
202
170
 
203
- 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).
204
173
 
205
174
  ## Credits
206
175
 
207
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).
208
177
 
209
- **What this fork improves**
210
-
211
- - **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.
212
- - **Planka v1 compatibility**: renamed v1 routes retried transparently on 404 — one build serves v2 and v1.
213
- - **Agent-optimized**: condensed output + `context` tool for scoped, id-free workflows.
214
- - **Lean**: one production dependency (MCP SDK), built-in `fetch`, compiled distroless image.
215
- - **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.
216
184
 
217
185
  ## License
218
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"],
@@ -103,9 +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") {
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)) {
109
111
  return data;
110
112
  }
111
113
  const hasItems = Array.isArray(source.items);
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,11 +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
  }
141
- // Fully custom operations (attachment download needs cookie-auth + binary
142
- // handling that the generic JSON engine cannot express).
161
+ // Fully custom operations (binary/cookie auth and board-wide search that
162
+ // the generic JSON engine cannot express).
143
163
  if (operation.custom === "downloadAttachment") {
144
164
  return downloadAttachment(input, scope);
145
165
  }
166
+ if (operation.custom === "findCards") {
167
+ return findCards(input, scope);
168
+ }
146
169
  // Construct URL with path parameters
147
170
  const canonicalPath = overridePath ?? operation.path;
148
171
  let actualPath = canonicalPath;
@@ -210,30 +233,48 @@ async function executeGroupedApiCall(groupedDef, input, overridePath, retryAttem
210
233
  // Planka file upload: v2 requires form fields `type` + `name`; v1
211
234
  // ignores extra fields, so one v2-shaped request serves both majors.
212
235
  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;
236
+ const isLink = typeof d.url === "string" && d.url !== "" && d.contentBase64 === undefined && d.text === undefined && d.filePath === undefined;
215
237
  const fd = new FormData();
216
238
  fd.append("type", isLink ? "link" : "file");
217
- fd.append("name", name);
218
239
  if (isLink) {
240
+ fd.append("name", typeof d.name === "string" && d.name !== "" ? d.name : "link");
219
241
  fd.append("url", String(d.url));
220
242
  }
221
243
  else {
222
244
  let bytes;
223
- let mimeType = typeof d.mimeType === "string" ? d.mimeType : "application/octet-stream";
224
- if (typeof d.contentBase64 === "string" && d.contentBase64 !== "") {
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 !== "") {
225
265
  const binary = atob(d.contentBase64);
226
266
  bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0));
227
267
  }
228
268
  else if (typeof d.text === "string") {
229
269
  bytes = new TextEncoder().encode(d.text);
230
- if (typeof d.mimeType !== "string")
270
+ if (!mimeType)
231
271
  mimeType = "text/plain";
232
272
  }
233
273
  else {
234
- return { success: false, error: "Provide data.contentBase64, data.text, or data.url for the attachment content" };
274
+ return { success: false, error: "Provide data.filePath, data.contentBase64, data.text, or data.url for the attachment content" };
235
275
  }
236
- fd.append("file", new Blob([bytes], { type: mimeType }), name);
276
+ fd.append("name", name !== "" ? name : "attachment");
277
+ fd.append("file", new Blob([bytes], { type: mimeType !== "" ? mimeType : "application/octet-stream" }), name !== "" ? name : "attachment");
237
278
  }
238
279
  body = fd;
239
280
  }
@@ -326,6 +367,8 @@ function normalizeAttachment(raw) {
326
367
  }
327
368
  return item;
328
369
  }
370
+ /** Inline decoded text for downloads up to this size; larger stays base64-only. */
371
+ const MAX_INLINE_TEXT_BYTES = 64 * 1024;
329
372
  /** Normalize every attachment record inside a `{item|items, included}` envelope. */
330
373
  function normalizeAttachmentsIn(data) {
331
374
  if (typeof data !== "object" || data === null)
@@ -347,7 +390,50 @@ function normalizeAttachmentsIn(data) {
347
390
  * against live 1.26.2 and 2.0.3). The attachment record must be located on
348
391
  * its owning card first, since there is no GET /attachments/{id}.
349
392
  */
350
- const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
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
+ }
351
437
  async function downloadAttachment(input, scope) {
352
438
  if (PLANKA_API_KEY) {
353
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" };
@@ -392,16 +478,32 @@ async function downloadAttachment(input, scope) {
392
478
  if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
393
479
  return { success: false, error: `Attachment too large to inline (${bytes.byteLength} bytes > ${MAX_ATTACHMENT_BYTES}); fetch it directly: ${target}` };
394
480
  }
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
- },
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,
404
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 };
405
507
  }
406
508
  /**
407
509
  * Post-processing for card calls:
@@ -524,7 +626,7 @@ if (!process.argv.includes("--healthcheck")) {
524
626
  function createMcpServer() {
525
627
  const server = new McpServer({
526
628
  name: "planka-mcp",
527
- version: "2.4.0",
629
+ version: "2.5.0",
528
630
  }, {
529
631
  capabilities: {
530
632
  tools: {},
@@ -548,15 +650,22 @@ function createMcpServer() {
548
650
  isError: true,
549
651
  };
550
652
  }
551
- // cards.create accepts data.attachments strip them from the API payload
552
- // and upload after the card exists.
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.
553
656
  let pendingAttachments = null;
554
657
  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;
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
+ }
560
669
  }
561
670
  const result = toolDef.groupedDef.name === "context"
562
671
  ? await handleContextCall(args, scope)
@@ -570,6 +679,18 @@ function createMcpServer() {
570
679
  if (result.success) {
571
680
  const wantsRaw = !PLANKA_CONDENSED_OUTPUT || args?.raw === true;
572
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
+ }
573
694
  return {
574
695
  content: [
575
696
  {
@@ -22,7 +22,6 @@ export declare const cardsTool: GroupedToolDefinition;
22
22
  /**
23
23
  * Attachments tool - upload, download, and manage card file attachments.
24
24
  *
25
- * Verified against live Planka 1.26.2 and 2.0.3:
26
25
  * - upload: POST /cards/{cardId}/attachments, multipart. v2 requires form
27
26
  * fields `type` ("file"|"link") and `name`; v1 ignores extra fields, so a
28
27
  * single v2-shaped request works on both.
@@ -241,7 +241,13 @@ export const cardsTool = {
241
241
  create: {
242
242
  method: "POST",
243
243
  path: "/lists/{listId}/cards",
244
- 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)",
245
251
  },
246
252
  update: {
247
253
  method: "PATCH",
@@ -254,30 +260,32 @@ export const cardsTool = {
254
260
  description: "Delete a card permanently",
255
261
  },
256
262
  },
257
- inputSchema: buildGroupedSchema(["list", "get", "getActions", "create", "update", "delete"], {
263
+ inputSchema: buildGroupedSchema(["list", "get", "getActions", "create", "find", "update", "delete"], {
258
264
  list: "Get cards from a list (requires listId)",
259
- get: "Get card details by ID",
265
+ get: "Get card details by ID (full context: description, tasks, attachments, actions)",
260
266
  getActions: "Get card activity log",
261
267
  create: "Create a new card",
268
+ find: "Find cards by name across a board",
262
269
  update: "Update card properties",
263
270
  delete: "Delete a card",
264
271
  }, {
265
272
  id: {
266
- description: "Card ID (for get, getActions, update, delete) or List ID (for list, create)",
267
- requiredFor: ["list", "get", "getActions", "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"],
268
275
  },
269
276
  data: {
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? }] }",
271
- 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"],
272
279
  properties: {
273
- name: { type: "string", description: "Card name", required: true },
274
- type: { type: "string", enum: ["project", "story"], description: "Card type", required: true },
275
- 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" },
276
283
  listId: { type: "string", description: "List ID (for create/move)" },
277
- 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)" },
278
286
  dueDate: { type: "string", description: "Due date (ISO 8601)" },
279
287
  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)" },
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)" },
281
289
  },
282
290
  },
283
291
  query: {
@@ -290,7 +298,6 @@ export const cardsTool = {
290
298
  /**
291
299
  * Attachments tool - upload, download, and manage card file attachments.
292
300
  *
293
- * Verified against live Planka 1.26.2 and 2.0.3:
294
301
  * - upload: POST /cards/{cardId}/attachments, multipart. v2 requires form
295
302
  * fields `type` ("file"|"link") and `name`; v1 ignores extra fields, so a
296
303
  * single v2-shaped request works on both.
@@ -16,8 +16,8 @@ export interface ToolOperation {
16
16
  description?: string;
17
17
  /** Send data as multipart/form-data instead of JSON (file uploads). */
18
18
  requestType?: "multipart";
19
- /** Fully custom handling in the engine (attachments download). */
20
- custom?: "downloadAttachment";
19
+ /** Fully custom handling in the engine (attachment download, card search). */
20
+ custom?: "downloadAttachment" | "findCards";
21
21
  }
22
22
  /**
23
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.4.0",
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",