@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 +67 -99
- package/dist/condense.js +6 -4
- package/dist/server.js +150 -29
- package/dist/tools/core/tools.d.ts +0 -1
- package/dist/tools/core/tools.js +20 -13
- package/dist/tools/types.d.ts +2 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
+
Requires Node.js ≥ 22.19 (or Bun ≥ 1.3). Debug: `npx -y @denisixnpm/planka-mcp 2>&1 | tee debug.log`.
|
|
91
68
|
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
163
|
-
|
|
|
164
|
-
|
|
|
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
|
|
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
|
|
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
|
|
180
|
-
|
|
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
|
|
191
|
-
denisix/planka-mcp
|
|
157
|
+
-e PLANKA_USERNAME -e PLANKA_PASSWORD \
|
|
158
|
+
denisix/planka-mcp
|
|
192
159
|
```
|
|
193
160
|
|
|
194
|
-
Compiled Bun binary on distroless (
|
|
161
|
+
Compiled Bun binary on distroless (no shell, no node_modules).
|
|
195
162
|
|
|
196
163
|
## Development
|
|
197
164
|
|
|
198
165
|
```bash
|
|
199
|
-
bun install
|
|
200
|
-
bun
|
|
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
|
-
|
|
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
|
-
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
107
|
-
// truncation must not touch them (size is
|
|
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
|
-
|
|
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 (
|
|
142
|
-
//
|
|
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
|
|
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
|
|
224
|
-
|
|
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 (
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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.
|
|
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
|
|
552
|
-
//
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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.
|
package/dist/tools/core/tools.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|
275
|
-
position: { type: "number", description: "Card position
|
|
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
|
-
|
|
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,
|
|
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.
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -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 (
|
|
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
|