@denisixnpm/planka-mcp 2.5.1 → 2.6.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
@@ -135,9 +135,20 @@ Every tool takes `action`, optional `id`/`data`/`query`, and optional `raw: true
135
135
 
136
136
  Actions per tool are enumerated in the MCP `tools/list` response. Live-verified semantics worth
137
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.
138
+ v1.26.2 lacks REST task-lists/custom-fields/webhooks (clean 404s); renamed v1 membership/label/
139
+ comment routes are retried automatically; `labels.list` and `cards.find` derive from `boards.get`
140
+ (Planka has no labels GET route on either major); link attachments to some URLs (e.g.
141
+ `example.com`) HTTP 500 inside Planka 2.0.3 itself.
142
+
143
+ ### If a tool reports "returned an HTML page"
144
+
145
+ Planka serves its web UI from the same origin as the API, so a route the server does not have may
146
+ answer `200 text/html` (the SPA's `index.html`) instead of `404`. The server refuses to pass that
147
+ off as data and tells you which call hit it. Usually it means the route is newer than your Planka:
148
+ on 1.26.x the server transparently retries the v1 route, or derives the answer from `boards.get` /
149
+ the card activity log and marks the result with a `_compat` note. If it names a route with no v1
150
+ equivalent (task lists), the error also names what to use instead. A page returned for *every*
151
+ call, including login, means `PLANKA_BASE_URL` points at a proxy or portal rather than at Planka.
141
152
 
142
153
  ## Multi-client SSE mode (optional)
143
154
 
@@ -164,12 +175,12 @@ Compiled Bun binary on distroless (no shell, no node_modules).
164
175
 
165
176
  ```bash
166
177
  bun install
167
- bun test # hermetic suite
168
- E2E_PLANKA_URL=… E2E_PLANKA_V1_URL=… bun test # + live Planka e2e (154 tests)
178
+ npm test # build + hermetic suite (222 tests)
179
+ npm run e2e # + live Planka 2.0.3 and 1.26.2: up, test, tear down
180
+ npm run check # lockfile + version-trio guards, then the suite (run before tagging)
169
181
  ```
170
182
 
171
- E2E stacks: `docker compose --profile e2e up -d`. Details in [CONTRIBUTING.md](CONTRIBUTING.md)
172
- and [CLAUDE.md](CLAUDE.md).
183
+ Details in [CONTRIBUTING.md](CONTRIBUTING.md) and [CLAUDE.md](CLAUDE.md).
173
184
 
174
185
  ## Credits
175
186
 
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Planka version compatibility: everything the engine needs to decide *which*
3
+ * route to use, with no network and no engine state. Kept separate from
4
+ * `server.ts` so the tables and the fingerprint logic can be unit-tested
5
+ * directly — driving them through the stdio transport would otherwise leave
6
+ * most branches unreachable.
7
+ *
8
+ * Every route table is keyed by `` `METHOD path` `` using the canonical
9
+ * (un-substituted) path from `src/tools/*​/tools.ts`. `test/compat.test.ts`
10
+ * asserts each key still resolves to a real operation, so renaming a path in
11
+ * a tool definition cannot silently orphan an entry here.
12
+ */
13
+ /** The Planka API generation: 1 = <= 1.26.x, 2 = 2.x, null = undetermined. */
14
+ export type PlankaMajor = 1 | 2 | null;
15
+ /**
16
+ * Planka serves its SPA from the same origin as the API, and a reverse-proxied
17
+ * deployment answers an unknown /api/* route with 200 + index.html instead of
18
+ * 404 (verified against a live 1.26.x host). Two things go wrong if this is not
19
+ * detected: the engine reports success and hands the agent a web page, and the
20
+ * 404-driven legacy-route fallback never fires. An HTML body always means
21
+ * "this route does not exist here".
22
+ *
23
+ * The body test is deliberately anchored: a JSON payload that merely *contains*
24
+ * markup somewhere (a card description, say) is data, not a catch-all page.
25
+ */
26
+ export declare function looksLikeHtml(contentType: string, body: string): boolean;
27
+ /**
28
+ * Decide the Planka generation from a `GET /api/config` response.
29
+ *
30
+ * v1's `show-config` returns exactly `{oidc, allowAllToCreateProjects}` to
31
+ * anyone; v2's `config/show` presents the whole Config model and is gated
32
+ * behind admin — so an auth failure is itself a v2 signal. Anything we cannot
33
+ * read (an HTML catch-all, a non-JSON body, a missing envelope) stays `null`
34
+ * rather than guessing: callers treat `null` as "unknown", not as "v1".
35
+ */
36
+ export declare function fingerprintConfig(status: number, contentType: string, body: string): PlankaMajor;
37
+ export declare function describePlankaMajor(major: PlankaMajor): string;
38
+ /**
39
+ * Planka <= 1.26.x routes that v2 renamed, verified against a live 1.26.2
40
+ * instance and its `server/config/routes.js`. When the v2 route is missing
41
+ * (a clean 404, or the SPA catch-all's 200 + index.html), the call is retried
42
+ * once with the legacy route so a single server build serves both API
43
+ * generations.
44
+ *
45
+ * The method is part of the key because `/cards/{cardId}/comments` needs a
46
+ * rename for POST but an emulator for GET.
47
+ *
48
+ * No custom-field-value entry: v1.26.x has no custom-field routes at all, so
49
+ * there is nothing to fall back to. (An entry here once mapped to a path
50
+ * containing a literal `$` — a template-literal typo that matched no real
51
+ * route; `test/compat.test.ts` now guards against that class of mistake.)
52
+ */
53
+ export declare const LEGACY_PATH_FALLBACKS: Record<string, string>;
54
+ /**
55
+ * Routes v1 does not have but whose answer can be derived from one it does.
56
+ * The emulator functions live in `server.ts` (they need the request engine);
57
+ * this list is the single definition of *which* routes get one, so the keys
58
+ * cannot drift apart from the implementations.
59
+ */
60
+ export declare const V1_EMULATED_ROUTES: readonly ["GET /cards/{cardId}/comments", "GET /lists/{id}", "GET /lists/{listId}/cards", "GET /bootstrap", "POST /task-lists/{taskListId}/tasks"];
61
+ export type V1EmulatedRoute = (typeof V1_EMULATED_ROUTES)[number];
62
+ /**
63
+ * v2-only routes with no v1 rename and no derivable source, and what an agent
64
+ * should reach for instead. Consulted only once a route is known to be missing,
65
+ * to turn a bare 404 or a masked HTML page into an actionable error.
66
+ */
67
+ export declare const V1_ALTERNATIVES: Record<string, string>;
package/dist/compat.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Planka version compatibility: everything the engine needs to decide *which*
3
+ * route to use, with no network and no engine state. Kept separate from
4
+ * `server.ts` so the tables and the fingerprint logic can be unit-tested
5
+ * directly — driving them through the stdio transport would otherwise leave
6
+ * most branches unreachable.
7
+ *
8
+ * Every route table is keyed by `` `METHOD path` `` using the canonical
9
+ * (un-substituted) path from `src/tools/*​/tools.ts`. `test/compat.test.ts`
10
+ * asserts each key still resolves to a real operation, so renaming a path in
11
+ * a tool definition cannot silently orphan an entry here.
12
+ */
13
+ /**
14
+ * Planka serves its SPA from the same origin as the API, and a reverse-proxied
15
+ * deployment answers an unknown /api/* route with 200 + index.html instead of
16
+ * 404 (verified against a live 1.26.x host). Two things go wrong if this is not
17
+ * detected: the engine reports success and hands the agent a web page, and the
18
+ * 404-driven legacy-route fallback never fires. An HTML body always means
19
+ * "this route does not exist here".
20
+ *
21
+ * The body test is deliberately anchored: a JSON payload that merely *contains*
22
+ * markup somewhere (a card description, say) is data, not a catch-all page.
23
+ */
24
+ export function looksLikeHtml(contentType, body) {
25
+ return contentType.includes("text/html") || /^\s*<(!doctype|html)\b/i.test(body);
26
+ }
27
+ /**
28
+ * Decide the Planka generation from a `GET /api/config` response.
29
+ *
30
+ * v1's `show-config` returns exactly `{oidc, allowAllToCreateProjects}` to
31
+ * anyone; v2's `config/show` presents the whole Config model and is gated
32
+ * behind admin — so an auth failure is itself a v2 signal. Anything we cannot
33
+ * read (an HTML catch-all, a non-JSON body, a missing envelope) stays `null`
34
+ * rather than guessing: callers treat `null` as "unknown", not as "v1".
35
+ */
36
+ export function fingerprintConfig(status, contentType, body) {
37
+ if (status === 401 || status === 403)
38
+ return 2;
39
+ if (looksLikeHtml(contentType, body))
40
+ return null;
41
+ let item;
42
+ try {
43
+ item = JSON.parse(body)?.item;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ if (!item || typeof item !== "object" || Array.isArray(item))
49
+ return null;
50
+ const keys = Object.keys(item);
51
+ const isV1 = keys.length <= 2 && keys.every(k => k === "oidc" || k === "allowAllToCreateProjects");
52
+ return isV1 ? 1 : 2;
53
+ }
54
+ export function describePlankaMajor(major) {
55
+ return major === null ? "Planka version could not be determined" : `detected Planka ${major}.x`;
56
+ }
57
+ /**
58
+ * Planka <= 1.26.x routes that v2 renamed, verified against a live 1.26.2
59
+ * instance and its `server/config/routes.js`. When the v2 route is missing
60
+ * (a clean 404, or the SPA catch-all's 200 + index.html), the call is retried
61
+ * once with the legacy route so a single server build serves both API
62
+ * generations.
63
+ *
64
+ * The method is part of the key because `/cards/{cardId}/comments` needs a
65
+ * rename for POST but an emulator for GET.
66
+ *
67
+ * No custom-field-value entry: v1.26.x has no custom-field routes at all, so
68
+ * there is nothing to fall back to. (An entry here once mapped to a path
69
+ * containing a literal `$` — a template-literal typo that matched no real
70
+ * route; `test/compat.test.ts` now guards against that class of mistake.)
71
+ */
72
+ export const LEGACY_PATH_FALLBACKS = {
73
+ "POST /boards/{boardId}/board-memberships": "/boards/{boardId}/memberships",
74
+ "POST /projects/{projectId}/project-managers": "/projects/{projectId}/managers",
75
+ "POST /cards/{cardId}/card-labels": "/cards/{cardId}/labels",
76
+ "DELETE /cards/{cardId}/card-labels/labelId:{labelId}": "/cards/{cardId}/labels/{labelId}",
77
+ "POST /cards/{cardId}/card-memberships": "/cards/{cardId}/memberships",
78
+ "DELETE /cards/{cardId}/card-memberships/userId:{userId}": "/cards/{cardId}/memberships?userId={userId}",
79
+ // v1 keeps comments in the activity log as `comment-actions`.
80
+ "POST /cards/{cardId}/comments": "/cards/{cardId}/comment-actions",
81
+ "PATCH /comments/{id}": "/comment-actions/{id}",
82
+ "DELETE /comments/{id}": "/comment-actions/{id}",
83
+ };
84
+ /**
85
+ * Routes v1 does not have but whose answer can be derived from one it does.
86
+ * The emulator functions live in `server.ts` (they need the request engine);
87
+ * this list is the single definition of *which* routes get one, so the keys
88
+ * cannot drift apart from the implementations.
89
+ */
90
+ export const V1_EMULATED_ROUTES = [
91
+ "GET /cards/{cardId}/comments",
92
+ "GET /lists/{id}",
93
+ "GET /lists/{listId}/cards",
94
+ "GET /bootstrap",
95
+ "POST /task-lists/{taskListId}/tasks",
96
+ ];
97
+ /**
98
+ * v2-only routes with no v1 rename and no derivable source, and what an agent
99
+ * should reach for instead. Consulted only once a route is known to be missing,
100
+ * to turn a bare 404 or a masked HTML page into an actionable error.
101
+ */
102
+ export const V1_ALTERNATIVES = {
103
+ "GET /task-lists/{id}": "Planka 1.x has no task-list entity — cards.get already returns the card's tasks",
104
+ "PATCH /task-lists/{id}": "Planka 1.x has no task-list entity — update the individual tasks instead",
105
+ "DELETE /task-lists/{id}": "Planka 1.x has no task-list entity — delete the individual tasks instead",
106
+ "POST /cards/{cardId}/task-lists": "Planka 1.x has no task-list entity — create tasks directly with tasks.create",
107
+ "POST /lists/{id}/clear": "Planka 1.x has no list-clear route — delete the cards individually",
108
+ "POST /lists/{id}/move-cards": "Planka 1.x has no bulk move route — move cards individually with cards.update",
109
+ };
@@ -14,5 +14,9 @@
14
14
  * - strings -> truncated to TEXT_LIMIT characters
15
15
  * - auth/context -> verbatim (tokens and local state must not be mangled)
16
16
  */
17
- /** Reduce one tool result (envelope, list, or raw value). */
18
- export declare function condenseResult(toolName: string, data: unknown): unknown;
17
+ /**
18
+ * Reduce one tool result (envelope, list, or raw value). `action` is optional
19
+ * and only consulted through ACTION_ENTITY_OVERRIDE, for actions that return
20
+ * something other than the tool's own entity.
21
+ */
22
+ export declare function condenseResult(toolName: string, data: unknown, action?: string): unknown;
package/dist/condense.js CHANGED
@@ -46,6 +46,17 @@ const ENTITY_FIELDS = {
46
46
  notifications: ["id", "type", "isRead", "cardId"],
47
47
  actions: ["id", "type", "cardId", "boardId", "userId", "createdAt", "data"],
48
48
  };
49
+ /**
50
+ * Actions whose payload is not the tool's own entity. Without this,
51
+ * `cards.getActions` is reduced with the `cards` whitelist — action records
52
+ * share only `id` with a card, so every action collapsed to `{id}` and the
53
+ * comment text, type and timestamp were dropped silently.
54
+ */
55
+ const ACTION_ENTITY_OVERRIDE = {
56
+ "cards.getActions": "actions",
57
+ "actions.boardActions": "actions",
58
+ "actions.cardActions": "actions",
59
+ };
49
60
  /** `included` keys that do not match their tool name verbatim. */
50
61
  const INCLUDED_KEY_TO_TOOL = {
51
62
  boardMemberships: "boardMembers",
@@ -64,9 +75,33 @@ const GENERIC_FIELDS = [
64
75
  ];
65
76
  /** Tools whose payloads are returned verbatim (tokens, local state). */
66
77
  const PASSTHROUGH_TOOLS = { auth: true, context: true };
78
+ /**
79
+ * `bootstrap.get` returns a bag of entity collections rather than one entity —
80
+ * `{user, projects, boards, notifications, …}` on v2, and the emulated shape on
81
+ * v1. No whitelist matches those keys, so `condenseEntity` would find nothing
82
+ * and fall back to returning the item verbatim, leaking the full user record.
83
+ * Treat the item like an `included` sidecar instead: arrays are condensed per
84
+ * entity, and `user` gets the `users` whitelist.
85
+ */
86
+ const BOOTSTRAP_SINGLE_ENTITY = { user: "users", config: "config" };
67
87
  function truncateText(value) {
68
88
  return value.length > TEXT_LIMIT ? value.slice(0, TEXT_LIMIT) + "…" : value;
69
89
  }
90
+ /**
91
+ * An action's payload lives in its `data` object (a comment's body is
92
+ * `data.text`). Keep the object but truncate its strings, so long comments do
93
+ * not slip past TEXT_LIMIT just because they are one level down.
94
+ */
95
+ function condenseActionData(value) {
96
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
97
+ return value;
98
+ }
99
+ const out = {};
100
+ for (const [key, inner] of Object.entries(value)) {
101
+ out[key] = typeof inner === "string" ? truncateText(inner) : inner;
102
+ }
103
+ return out;
104
+ }
70
105
  function condenseEntity(toolName, entity) {
71
106
  if (typeof entity !== "object" || entity === null || Array.isArray(entity)) {
72
107
  return entity;
@@ -77,11 +112,42 @@ function condenseEntity(toolName, entity) {
77
112
  for (const field of whitelist) {
78
113
  const value = source[field];
79
114
  if (value !== null && value !== undefined) {
80
- out[field] = typeof value === "string" ? truncateText(value) : value;
115
+ if (field === "data") {
116
+ out[field] = condenseActionData(value);
117
+ }
118
+ else {
119
+ out[field] = typeof value === "string" ? truncateText(value) : value;
120
+ }
81
121
  }
82
122
  }
123
+ // Deliberately NOT widened to "only `id` survived -> pass the entity
124
+ // through": whitelists are the only thing keeping fields like passwordHash
125
+ // out of an agent's context. A mismatched whitelist is fixed by mapping the
126
+ // action to the right entity (ACTION_ENTITY_OVERRIDE), not by leaking.
83
127
  return Object.keys(out).length > 0 ? out : entity;
84
128
  }
129
+ /** Reduce the entity bag `bootstrap.get` returns as its `item`. */
130
+ function condenseBootstrapItem(item) {
131
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
132
+ return item;
133
+ }
134
+ const out = {};
135
+ for (const [key, value] of Object.entries(item)) {
136
+ if (Array.isArray(value)) {
137
+ out[key] = value.map(entity => condenseEntity(INCLUDED_KEY_TO_TOOL[key] ?? key, entity));
138
+ }
139
+ else if (key === "included" && value && typeof value === "object") {
140
+ out[key] = condenseIncluded(value);
141
+ }
142
+ else if (value !== null && value !== undefined && typeof value === "object") {
143
+ out[key] = condenseEntity(BOOTSTRAP_SINGLE_ENTITY[key] ?? key, value);
144
+ }
145
+ else if (value !== null && value !== undefined) {
146
+ out[key] = typeof value === "string" ? truncateText(value) : value;
147
+ }
148
+ }
149
+ return out;
150
+ }
85
151
  /** Reduce every entity array inside an `included` sidecar object. */
86
152
  function condenseIncluded(included) {
87
153
  const out = {};
@@ -93,13 +159,18 @@ function condenseIncluded(included) {
93
159
  }
94
160
  return out;
95
161
  }
96
- /** Reduce one tool result (envelope, list, or raw value). */
97
- export function condenseResult(toolName, data) {
162
+ /**
163
+ * Reduce one tool result (envelope, list, or raw value). `action` is optional
164
+ * and only consulted through ACTION_ENTITY_OVERRIDE, for actions that return
165
+ * something other than the tool's own entity.
166
+ */
167
+ export function condenseResult(toolName, data, action) {
98
168
  if (PASSTHROUGH_TOOLS[toolName]) {
99
169
  return data;
100
170
  }
171
+ const entityName = (action && ACTION_ENTITY_OVERRIDE[`${toolName}.${action}`]) || toolName;
101
172
  if (Array.isArray(data)) {
102
- return data.map(item => condenseEntity(toolName, item));
173
+ return data.map(item => condenseEntity(entityName, item));
103
174
  }
104
175
  if (typeof data === "object" && data !== null) {
105
176
  const source = data;
@@ -115,10 +186,17 @@ export function condenseResult(toolName, data) {
115
186
  if (hasItems || hasItem) {
116
187
  const out = {};
117
188
  if (hasItems) {
118
- out.items = source.items.map(item => condenseEntity(toolName, item));
189
+ out.items = source.items.map(item => condenseEntity(entityName, item));
119
190
  }
120
191
  if (hasItem) {
121
- out.item = condenseEntity(toolName, source.item);
192
+ out.item = entityName === "bootstrap"
193
+ ? condenseBootstrapItem(source.item)
194
+ : condenseEntity(entityName, source.item);
195
+ }
196
+ // Compatibility notes from the Planka 1.x emulators must survive the
197
+ // envelope rebuild — they tell the agent where the data came from.
198
+ if (typeof source._compat === "string") {
199
+ out._compat = source._compat;
122
200
  }
123
201
  if (source.included && typeof source.included === "object") {
124
202
  const included = condenseIncluded(source.included);
@@ -128,7 +206,7 @@ export function condenseResult(toolName, data) {
128
206
  }
129
207
  return out;
130
208
  }
131
- return condenseEntity(toolName, data);
209
+ return condenseEntity(entityName, data);
132
210
  }
133
211
  return data;
134
212
  }
package/dist/server.js CHANGED
@@ -10,6 +10,7 @@ import { basename, extname } from "node:path";
10
10
  // Import tool definitions
11
11
  import { getEnabledTools, toolCounts } from "./tools/index.js";
12
12
  import { condenseResult } from "./condense.js";
13
+ import { LEGACY_PATH_FALLBACKS, V1_ALTERNATIVES, V1_EMULATED_ROUTES, looksLikeHtml, fingerprintConfig, describePlankaMajor, } from "./compat.js";
13
14
  /** Common file extensions -> MIME type (uploads via data.filePath). */
14
15
  const MIME_BY_EXT = {
15
16
  ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
@@ -83,7 +84,17 @@ async function login() {
83
84
  if (!res.ok) {
84
85
  throw new Error(`Authentication failed: ${res.status} - ${text}`);
85
86
  }
86
- const data = JSON.parse(text);
87
+ if (looksLikeHtml(res.headers.get("content-type") ?? "", text)) {
88
+ throw new Error(`Authentication failed: ${loginUrl} returned an HTML page instead of JSON. ` +
89
+ `Check PLANKA_BASE_URL — it must point at the Planka origin itself, not at a proxy or login portal.`);
90
+ }
91
+ let data;
92
+ try {
93
+ data = JSON.parse(text);
94
+ }
95
+ catch {
96
+ throw new Error(`Authentication failed: login response was not JSON - ${truncate(text, 200)}`);
97
+ }
87
98
  if (typeof data.item !== "string" || data.item.length === 0) {
88
99
  throw new Error(`Authentication failed: unexpected login response - ${truncate(text, 200)}`);
89
100
  }
@@ -105,6 +116,39 @@ function sleep(ms) {
105
116
  function shouldRetryStatus(statusCode) {
106
117
  return statusCode === 408 || statusCode === 429 || statusCode >= 500;
107
118
  }
119
+ // ----- Planka generation detection -----
120
+ /**
121
+ * Resolved lazily and only where it changes behaviour (error wording, forked
122
+ * routes); the happy path never probes. The decision itself lives in
123
+ * `compat.fingerprintConfig` — this only owns the fetch and the cache.
124
+ */
125
+ let plankaMajor = null;
126
+ let inflightVersionProbe = null;
127
+ async function probePlankaMajor() {
128
+ try {
129
+ const res = await fetch(`${PLANKA_BASE_URL}/api/config`, {
130
+ headers: { Accept: "application/json" },
131
+ signal: AbortSignal.timeout(PLANKA_HTTP_TIMEOUT_MS),
132
+ });
133
+ // 401/403 needs no body: v2 gates GET /config behind admin, v1 does not.
134
+ const body = res.status === 401 || res.status === 403 ? "" : await res.text();
135
+ const major = fingerprintConfig(res.status, res.headers.get("content-type") ?? "", body);
136
+ if (major !== null)
137
+ plankaMajor = major;
138
+ return major;
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
144
+ async function detectPlankaMajor() {
145
+ if (plankaMajor !== null)
146
+ return plankaMajor;
147
+ inflightVersionProbe ??= probePlankaMajor().finally(() => {
148
+ inflightVersionProbe = null;
149
+ });
150
+ return inflightVersionProbe;
151
+ }
108
152
  function formatAttempt(attempt) {
109
153
  return `${attempt + 1}/${MAX_HTTP_RETRIES + 1}`;
110
154
  }
@@ -133,20 +177,6 @@ function buildTools() {
133
177
  }));
134
178
  }
135
179
  // ----- Execute API call for grouped tools -----
136
- /**
137
- * Planka <= 1.26.x routes that v2 renamed, verified against a live 1.26.2
138
- * instance. When the v2 route answers 404, the call is retried once with the
139
- * legacy route so a single server build serves both API generations.
140
- */
141
- const LEGACY_PATH_FALLBACKS = {
142
- "/boards/{boardId}/board-memberships": "/boards/{boardId}/memberships",
143
- "/projects/{projectId}/project-managers": "/projects/{projectId}/managers",
144
- "/cards/{cardId}/card-labels": "/cards/{cardId}/labels",
145
- "/cards/{cardId}/card-labels/labelId:{labelId}": "/cards/{cardId}/labels/{labelId}",
146
- "/cards/{cardId}/card-memberships": "/cards/{cardId}/memberships",
147
- "/cards/{cardId}/card-memberships/userId:{userId}": "/cards/{cardId}/memberships?userId={userId}",
148
- "/cards/{cardId}/custom-field-values/customFieldGroupId:{customFieldGroupId}:customFieldId:{customFieldId}": "/cards/{cardId}/custom-field-values/customFieldGroupId:{customFieldGroupId}:customFieldId:${customFieldId}",
149
- };
150
180
  async function executeGroupedApiCall(groupedDef, input, overridePath, retryAttempt = 0, unauthorizedRetried = false, allowLegacyPathFallback = true, scope) {
151
181
  try {
152
182
  const action = input?.action;
@@ -166,6 +196,9 @@ async function executeGroupedApiCall(groupedDef, input, overridePath, retryAttem
166
196
  if (operation.custom === "findCards") {
167
197
  return findCards(input, scope);
168
198
  }
199
+ if (operation.custom === "listLabels") {
200
+ return listLabels(input, scope);
201
+ }
169
202
  // Construct URL with path parameters
170
203
  const canonicalPath = overridePath ?? operation.path;
171
204
  let actualPath = canonicalPath;
@@ -306,7 +339,12 @@ async function executeGroupedApiCall(groupedDef, input, overridePath, retryAttem
306
339
  // Keep as text if JSON parsing fails
307
340
  }
308
341
  }
309
- if (res.ok) {
342
+ // An HTML body on a JSON route is the SPA catch-all, never a result. Treat
343
+ // it as the 404 the server should have sent so the fallbacks below engage.
344
+ const routeMissing = res.ok && looksLikeHtml(contentType, text);
345
+ const effectiveStatus = routeMissing ? 404 : res.status;
346
+ const routeKey = `${methodUpper} ${canonicalPath}`;
347
+ if (res.ok && !routeMissing) {
310
348
  return { success: true, data };
311
349
  }
312
350
  else {
@@ -318,15 +356,36 @@ async function executeGroupedApiCall(groupedDef, input, overridePath, retryAttem
318
356
  console.error(`[auth] received 401 for ${groupedDef.name}.${action} (${methodUpper} ${actualPath}); clearing cached token and retrying once`);
319
357
  return executeGroupedApiCall(groupedDef, input, overridePath, retryAttempt, true, allowLegacyPathFallback, scope);
320
358
  }
321
- // Legacy-version fallback: when a v2 route 404s, retry once with the
322
- // older Planka (<= 1.26.x) route it replaced. All variants below are
323
- // verified against a live Planka 1.26.2 instance.
324
- if (res.status === 404 && allowLegacyPathFallback) {
325
- const legacyPath = LEGACY_PATH_FALLBACKS[canonicalPath];
359
+ // Legacy-version fallback: when a v2 route is missing, retry once with
360
+ // the older Planka (<= 1.26.x) route it replaced, or derive the answer
361
+ // from a route v1 does have. All variants are verified against a live
362
+ // Planka 1.26.2 instance and its routes.js.
363
+ if (effectiveStatus === 404 && allowLegacyPathFallback) {
364
+ const legacyPath = LEGACY_PATH_FALLBACKS[routeKey];
326
365
  if (legacyPath && legacyPath !== canonicalPath) {
327
- console.error(`[compat] 404 for ${groupedDef.name}.${action} (${methodUpper} ${actualPath}); retrying with legacy route ${legacyPath}`);
366
+ console.error(`[compat] route missing for ${groupedDef.name}.${action} (${methodUpper} ${actualPath}); retrying with legacy route ${legacyPath}`);
328
367
  return executeGroupedApiCall(groupedDef, input, legacyPath, retryAttempt, unauthorizedRetried, false, scope);
329
368
  }
369
+ // Emulators reshape the request, so unlike a plain rename a misfire on
370
+ // v2 would mask a genuine 404 (a bad taskListId, say). Only derive when
371
+ // the host is not known to be v2.
372
+ const emulate = emulatorFor(routeKey);
373
+ if (emulate && (await detectPlankaMajor()) !== 2) {
374
+ console.error(`[compat] route missing for ${groupedDef.name}.${action} (${methodUpper} ${actualPath}); deriving the result from Planka 1.x routes`);
375
+ return emulate(input, scope);
376
+ }
377
+ }
378
+ if (routeMissing) {
379
+ // Never hand the SPA page back to the agent: report the call instead.
380
+ const major = await detectPlankaMajor();
381
+ const hint = V1_ALTERNATIVES[routeKey];
382
+ return {
383
+ success: false,
384
+ error: `Planka answered ${methodUpper} /api${actualPath} with HTTP ${res.status} and an HTML page ` +
385
+ `(content-type: ${contentType || "none"}, ${text.length} bytes) instead of JSON. ` +
386
+ `That route does not exist on this server (${describePlankaMajor(major)}); its SPA catch-all served index.html.` +
387
+ (hint ? ` ${hint}.` : ""),
388
+ };
330
389
  }
331
390
  if (shouldRetryStatus(res.status) && retryAttempt < MAX_HTTP_RETRIES) {
332
391
  const delayMs = retryDelayMs(retryAttempt);
@@ -335,9 +394,13 @@ async function executeGroupedApiCall(groupedDef, input, overridePath, retryAttem
335
394
  return executeGroupedApiCall(groupedDef, input, overridePath, retryAttempt + 1, unauthorizedRetried, allowLegacyPathFallback, scope);
336
395
  }
337
396
  console.error(`[error] API call failed for ${groupedDef.name}.${action} (${methodUpper} ${actualPath}) with HTTP ${res.status}`);
397
+ // A clean 404 on a v2-only route deserves the same pointer the HTML
398
+ // branch gives, so agents on Planka 1.x learn what to use instead.
399
+ const missingHint = res.status === 404 ? V1_ALTERNATIVES[routeKey] : undefined;
338
400
  return {
339
401
  success: false,
340
- error: `HTTP ${res.status}: ${truncate(typeof data === 'string' ? data : JSON.stringify(data), 2000)}`,
402
+ error: `HTTP ${res.status}: ${truncate(typeof data === 'string' ? data : JSON.stringify(data), 2000)}` +
403
+ (missingHint ? ` — ${missingHint}.` : ""),
341
404
  };
342
405
  }
343
406
  }
@@ -434,6 +497,155 @@ async function findCards(input, scope) {
434
497
  },
435
498
  };
436
499
  }
500
+ /**
501
+ * List a board's labels. Neither Planka major exposes GET /labels, so the
502
+ * labels tool would otherwise be write-only and agents had to know to reach
503
+ * for boards.get; one board GET supplies them on both majors.
504
+ */
505
+ async function listLabels(input, scope) {
506
+ const boardId = String(input?.data?.boardId ?? input?.id ?? scope?.boardId ?? "");
507
+ if (!boardId) {
508
+ return { success: false, error: "Provide data.boardId (or id), or select a board via the context tool" };
509
+ }
510
+ const boardsTool = toolMap.get("boards");
511
+ if (!boardsTool) {
512
+ return { success: false, error: "boards tool unavailable; cannot list labels" };
513
+ }
514
+ const boardRes = await executeGroupedApiCall(boardsTool.groupedDef, { action: "get", id: boardId }, undefined, 0, false, true, scope);
515
+ if (!boardRes.success) {
516
+ return boardRes;
517
+ }
518
+ const included = (boardRes.data?.included ?? {});
519
+ return {
520
+ success: true,
521
+ data: {
522
+ items: included.labels ?? [],
523
+ _compat: `derived from GET /boards/${boardId} (Planka has no labels GET route on either major)`,
524
+ },
525
+ };
526
+ }
527
+ /** Run one action of another grouped tool, without any fallback recursion. */
528
+ async function callTool(toolName, input, scope) {
529
+ const tool = toolMap.get(toolName);
530
+ if (!tool) {
531
+ return { success: false, error: `${toolName} tool unavailable; cannot derive the result` };
532
+ }
533
+ return executeGroupedApiCall(tool.groupedDef, input, undefined, 0, false, false, scope);
534
+ }
535
+ /** Fetch a board once and hand back its `included` sidecar. */
536
+ async function includedOfBoard(boardId, scope) {
537
+ const res = await callTool("boards", { action: "get", id: boardId }, scope);
538
+ if (!res.success)
539
+ return { error: res.error };
540
+ return { included: (res.data?.included ?? {}) };
541
+ }
542
+ /**
543
+ * v1 has no GET /cards/{id}/comments: comments live in the activity log as
544
+ * `commentCard` actions with the body under `data.text`.
545
+ */
546
+ const emulateCommentsList = async (input, scope) => {
547
+ const cardId = String(input?.data?.cardId ?? input?.id ?? scope?.cardId ?? "");
548
+ if (!cardId) {
549
+ return { success: false, error: "Provide id (card ID), or select the card via the context tool" };
550
+ }
551
+ const res = await callTool("cards", { action: "getActions", id: cardId }, scope);
552
+ if (!res.success)
553
+ return res;
554
+ const items = (res.data?.items ?? [])
555
+ .filter(a => a.type === "commentCard")
556
+ .map(a => ({
557
+ id: a.id,
558
+ text: a.data?.text ?? "",
559
+ cardId: a.cardId ?? cardId,
560
+ userId: a.userId,
561
+ createdAt: a.createdAt,
562
+ updatedAt: a.updatedAt,
563
+ }));
564
+ return { success: true, data: { items, _compat: "derived from GET /cards/{id}/actions (Planka 1.x stores comments as commentCard actions)" } };
565
+ };
566
+ /** v1 has no GET /lists/{id}; the board payload carries every list. */
567
+ const emulateListGet = async (input, scope) => {
568
+ const listId = String(input?.data?.listId ?? input?.id ?? scope?.listId ?? "");
569
+ const boardId = String(input?.data?.boardId ?? scope?.boardId ?? "");
570
+ if (!listId) {
571
+ return { success: false, error: "Provide id (list ID), or select the list via the context tool" };
572
+ }
573
+ if (!boardId) {
574
+ return { success: false, error: "Planka 1.x has no GET /lists/{id}. Provide data.boardId (or select a board via the context tool) so the list can be read from boards.get." };
575
+ }
576
+ const { included, error } = await includedOfBoard(boardId, scope);
577
+ if (error)
578
+ return { success: false, error };
579
+ const item = (included.lists ?? []).find(l => String(l.id) === listId);
580
+ if (!item) {
581
+ return { success: false, error: `List ${listId} not found on board ${boardId}` };
582
+ }
583
+ return { success: true, data: { item, _compat: `derived from GET /boards/${boardId} (Planka 1.x has no list route)` } };
584
+ };
585
+ /** v1 has no GET /lists/{id}/cards; filter the board's cards by list. */
586
+ const emulateListCards = async (input, scope) => {
587
+ const listId = String(input?.data?.listId ?? input?.id ?? scope?.listId ?? "");
588
+ const boardId = String(input?.data?.boardId ?? scope?.boardId ?? "");
589
+ if (!listId) {
590
+ return { success: false, error: "Provide id (list ID), or select the list via the context tool" };
591
+ }
592
+ if (!boardId) {
593
+ return { success: false, error: "Planka 1.x has no GET /lists/{id}/cards. Provide data.boardId (or select a board via the context tool) so the cards can be read from boards.get." };
594
+ }
595
+ const { included, error } = await includedOfBoard(boardId, scope);
596
+ if (error)
597
+ return { success: false, error };
598
+ const items = (included.cards ?? []).filter(c => String(c.listId) === listId);
599
+ return { success: true, data: { items, _compat: `derived from GET /boards/${boardId} (Planka 1.x has no per-list card route)` } };
600
+ };
601
+ /** v1 has no /bootstrap; compose the same working set from /projects + /config. */
602
+ const emulateBootstrap = async (_input, scope) => {
603
+ const projects = await callTool("projects", { action: "list" }, scope);
604
+ if (!projects.success)
605
+ return projects;
606
+ const item = {
607
+ projects: projects.data?.items ?? [],
608
+ included: projects.data?.included ?? {},
609
+ };
610
+ const config = await callTool("config", { action: "get" }, scope);
611
+ if (config.success)
612
+ item.config = config.data?.item;
613
+ return { success: true, data: { item, _compat: "derived from GET /projects (+ /config) — Planka 1.x has no /bootstrap route" } };
614
+ };
615
+ /**
616
+ * v1 hangs tasks off the card (POST /cards/{cardId}/tasks) instead of off a
617
+ * task list, so this is a re-parent rather than a rename and cannot live in
618
+ * LEGACY_PATH_FALLBACKS.
619
+ */
620
+ const emulateTaskCreate = async (input, scope) => {
621
+ const cardId = String(input?.data?.cardId ?? scope?.cardId ?? "");
622
+ if (!cardId) {
623
+ return { success: false, error: "Planka 1.x creates tasks on the card, not on a task list. Provide data.cardId (or select the card via the context tool)." };
624
+ }
625
+ const tasksTool = toolMap.get("tasks");
626
+ if (!tasksTool) {
627
+ return { success: false, error: "tasks tool unavailable; cannot create the task" };
628
+ }
629
+ const { taskListId: _omit, ...data } = (input?.data ?? {});
630
+ return executeGroupedApiCall(tasksTool.groupedDef, { action: "create", id: cardId, data: { ...data, cardId } }, "/cards/{cardId}/tasks", 0, false, false, scope);
631
+ };
632
+ /**
633
+ * Keyed like LEGACY_PATH_FALLBACKS: `METHOD canonicalPath`. The key set is
634
+ * `V1_EMULATED_ROUTES` in compat.ts — typing the record by it means adding a
635
+ * route there without an implementation (or vice versa) is a compile error.
636
+ */
637
+ const V1_EMULATORS = {
638
+ "GET /cards/{cardId}/comments": emulateCommentsList,
639
+ "GET /lists/{id}": emulateListGet,
640
+ "GET /lists/{listId}/cards": emulateListCards,
641
+ "GET /bootstrap": emulateBootstrap,
642
+ "POST /task-lists/{taskListId}/tasks": emulateTaskCreate,
643
+ };
644
+ function emulatorFor(routeKey) {
645
+ return V1_EMULATED_ROUTES.includes(routeKey)
646
+ ? V1_EMULATORS[routeKey]
647
+ : undefined;
648
+ }
437
649
  async function downloadAttachment(input, scope) {
438
650
  if (PLANKA_API_KEY) {
439
651
  return { success: false, error: "Attachment downloads require PLANKA_USERNAME/PLANKA_PASSWORD auth: Planka serves files from a cookie-authenticated route that rejects API keys" };
@@ -474,6 +686,11 @@ async function downloadAttachment(input, scope) {
474
686
  if (!res.ok) {
475
687
  return { success: false, error: `Attachment download failed: HTTP ${res.status}` };
476
688
  }
689
+ const servedType = res.headers.get("content-type") ?? "";
690
+ if (servedType.includes("text/html") && !String(record.mimeType ?? "").startsWith("text/html")) {
691
+ // The SPA catch-all again: index.html must never be handed back as file bytes.
692
+ return { success: false, error: `Attachment download returned an HTML page for ${target} — the file route is unavailable on this Planka` };
693
+ }
477
694
  const bytes = new Uint8Array(await res.arrayBuffer());
478
695
  if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
479
696
  return { success: false, error: `Attachment too large to inline (${bytes.byteLength} bytes > ${MAX_ATTACHMENT_BYTES}); fetch it directly: ${target}` };
@@ -528,6 +745,24 @@ async function enrichCardResult(action, data, pendingAttachments, cardId, scope)
528
745
  catch (actionsError) {
529
746
  console.error(`[cards.get] actions fetch failed for card ${cardId}: ${actionsError instanceof Error ? actionsError.message : String(actionsError)}`);
530
747
  }
748
+ // v2 keeps comments on their own route, so the activity log above does not
749
+ // carry them; v1 answers this from the same log. Folding them in here means
750
+ // one cards.get returns the whole working context on both majors instead of
751
+ // forcing a second comments.list round trip. Best-effort, like actions.
752
+ const commentsTool = toolMap.get("comments");
753
+ if (commentsTool) {
754
+ try {
755
+ const comments = await executeGroupedApiCall(commentsTool.groupedDef, { action: "list", id: cardId }, undefined, 0, false, true, scope);
756
+ if (comments.success && Array.isArray(comments.data?.items)) {
757
+ if (!data.included || typeof data.included !== "object")
758
+ data.included = {};
759
+ data.included.comments = comments.data.items;
760
+ }
761
+ }
762
+ catch (commentsError) {
763
+ console.error(`[cards.get] comments fetch failed for card ${cardId}: ${commentsError instanceof Error ? commentsError.message : String(commentsError)}`);
764
+ }
765
+ }
531
766
  }
532
767
  if (action === "create" && pendingAttachments && pendingAttachments.length > 0) {
533
768
  const createdCardId = String(data?.item?.id ?? cardId ?? "");
@@ -626,7 +861,7 @@ if (!process.argv.includes("--healthcheck")) {
626
861
  function createMcpServer() {
627
862
  const server = new McpServer({
628
863
  name: "planka-mcp",
629
- version: "2.5.1",
864
+ version: "2.6.0",
630
865
  }, {
631
866
  capabilities: {
632
867
  tools: {},
@@ -678,7 +913,9 @@ function createMcpServer() {
678
913
  }
679
914
  if (result.success) {
680
915
  const wantsRaw = !PLANKA_CONDENSED_OUTPUT || args?.raw === true;
681
- const payload = wantsRaw ? result.data : condenseResult(toolDef.groupedDef.name, result.data);
916
+ const payload = wantsRaw
917
+ ? result.data
918
+ : condenseResult(toolDef.groupedDef.name, result.data, typeof args?.action === "string" ? args.action : undefined);
682
919
  const asRecord = (typeof payload === "object" && payload !== null ? payload : undefined);
683
920
  // Image downloads additionally ship an MCP image content block so
684
921
  // clients render the picture directly instead of showing base64.
@@ -402,7 +402,9 @@ export const commentsTool = {
402
402
  */
403
403
  export const tasksTool = {
404
404
  name: "tasks",
405
- description: "Manage task lists and tasks on Planka cards. Tasks are checklist items within a card.",
405
+ description: "Manage task lists and tasks on Planka cards. Tasks are checklist items within a card. " +
406
+ "There is no 'list' action — a card's tasks already come back with cards.get; " +
407
+ "getList reads one task list by ID (Planka 2.x only).",
406
408
  operations: {
407
409
  getList: {
408
410
  method: "GET",
@@ -456,6 +458,14 @@ export const labelsTool = {
456
458
  name: "labels",
457
459
  description: "Manage labels on Planka boards and cards. Labels help categorize and filter cards.",
458
460
  operations: {
461
+ list: {
462
+ // No Planka major exposes a labels GET route; the board payload carries
463
+ // them, so the engine derives this from boards.get.
464
+ method: "GET",
465
+ path: "/boards/{id}",
466
+ description: "List a board's labels",
467
+ custom: "listLabels",
468
+ },
459
469
  create: {
460
470
  method: "POST",
461
471
  path: "/boards/{boardId}/labels",
@@ -482,7 +492,8 @@ export const labelsTool = {
482
492
  description: "Remove a label from a card",
483
493
  },
484
494
  },
485
- inputSchema: buildGroupedSchema(["create", "update", "delete", "addToCard", "removeFromCard"], {
495
+ inputSchema: buildGroupedSchema(["list", "create", "update", "delete", "addToCard", "removeFromCard"], {
496
+ list: "List the labels defined on a board",
486
497
  create: "Create a label on a board",
487
498
  update: "Update a label's name, color, or position",
488
499
  delete: "Delete a label from a board",
@@ -490,8 +501,8 @@ export const labelsTool = {
490
501
  removeFromCard: "Remove a label from a card",
491
502
  }, {
492
503
  id: {
493
- description: "Board ID (for create), Label ID (for update, delete), or Card ID (for addToCard, removeFromCard)",
494
- requiredFor: ["create", "update", "delete", "addToCard", "removeFromCard"],
504
+ description: "Board ID (for list, create), Label ID (for update, delete), or Card ID (for addToCard, removeFromCard)",
505
+ requiredFor: ["list", "create", "update", "delete", "addToCard", "removeFromCard"],
495
506
  },
496
507
  data: {
497
508
  description: "Label data: { name?: string, color: string (required), position: number (required) } for create/update, { labelId: string } for addToCard/removeFromCard. Colors: muddy-grey, autumn-leafs, morning-sky, antique-blue, egg-yellow, desert-sand, dark-granite, fresh-salad, lagoon-blue, midnight-blue, light-orange, pumpkin-orange, light-concrete, sunny-grass, navy-blue, lilac-eyes, apricot-red, orange-peel, silver-glint, bright-moss, deep-ocean, summer-sky, berry-red, light-cocoa, grey-stone, tank-green, coral-green, sugar-plum, pink-tulip, shady-rust, wet-rock, wet-moss, turquoise-sea, lavender-fields, piggy-red, light-mud, gun-metal, modern-green, french-coast, sweet-lilac, red-burgundy, pirate-gold",
@@ -16,8 +16,12 @@ 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 (attachment download, card search). */
20
- custom?: "downloadAttachment" | "findCards";
19
+ /**
20
+ * Fully custom handling in the engine: attachment download (cookie auth),
21
+ * board-wide card search, and label listing (no Planka major has a labels
22
+ * GET route — the board payload carries them).
23
+ */
24
+ custom?: "downloadAttachment" | "findCards" | "listLabels";
21
25
  }
22
26
  /**
23
27
  * 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.5.1",
3
+ "version": "2.6.0",
4
4
  "description": "MCP server for Planka - Real-Time Collaborative Kanban Board",
5
5
  "main": "dist/server.js",
6
6
  "type": "module",
@@ -18,10 +18,16 @@
18
18
  "dev:sse": "MCP_TRANSPORT=sse bun src/server.ts",
19
19
  "prepublishOnly": "npm run build",
20
20
  "test": "bun run build && bun test",
21
- "test:tools": "bun test test/tools.test.ts",
21
+ "test:tools": "bun run build && bun test test/tools.test.ts",
22
22
  "test:build": "bun run build && bun test test/build.test.ts",
23
- "test:server": "bun test test/server.test.ts",
24
- "test:ci": "bun run build && bun test"
23
+ "test:server": "bun run build && bun test test/server.test.ts",
24
+ "check": "npm run guard:lockfiles && npm run guard:version && npm test",
25
+ "guard:version": "node scripts/version-sync.mjs",
26
+ "guard:lockfiles": "node scripts/guard-lockfiles.mjs",
27
+ "version:set": "node scripts/version-sync.mjs --set",
28
+ "e2e": "node scripts/e2e.mjs",
29
+ "e2e:up": "docker compose --profile e2e up -d",
30
+ "e2e:down": "docker compose --profile e2e down -v"
25
31
  },
26
32
  "keywords": [
27
33
  "mcp",