@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 +70 -86
- package/dist/condense.js +10 -3
- package/dist/server.js +322 -3
- package/dist/tools/core/index.d.ts +2 -2
- package/dist/tools/core/index.js +3 -2
- package/dist/tools/core/tools.d.ts +13 -0
- package/dist/tools/core/tools.js +90 -12
- package/dist/tools/optional/index.d.ts +2 -2
- package/dist/tools/optional/index.js +2 -3
- package/dist/tools/optional/tools.d.ts +0 -4
- package/dist/tools/optional/tools.js +0 -43
- package/dist/tools/types.d.ts +4 -0
- 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,21 +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`.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
|
147
|
-
|
|
|
148
|
-
|
|
|
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
|
|
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
|
|
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
|
|
164
|
-
|
|
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
|
|
175
|
-
denisix/planka-mcp
|
|
157
|
+
-e PLANKA_USERNAME -e PLANKA_PASSWORD \
|
|
158
|
+
denisix/planka-mcp
|
|
176
159
|
```
|
|
177
160
|
|
|
178
|
-
Compiled Bun binary on distroless (
|
|
161
|
+
Compiled Bun binary on distroless (no shell, no node_modules).
|
|
179
162
|
|
|
180
163
|
## Development
|
|
181
164
|
|
|
182
165
|
```bash
|
|
183
|
-
bun install
|
|
184
|
-
bun
|
|
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
|
-
|
|
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
|
-
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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
|
*/
|
package/dist/tools/core/index.js
CHANGED
|
@@ -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
|
*/
|
package/dist/tools/core/tools.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|
269
|
-
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" },
|
|
270
283
|
listId: { type: "string", description: "List ID (for create/move)" },
|
|
271
|
-
|
|
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,
|
|
6
|
-
export { actionsTool,
|
|
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,
|
|
2
|
-
export { actionsTool,
|
|
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
|
*/
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -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
|