@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 +18 -7
- package/dist/compat.d.ts +67 -0
- package/dist/compat.js +109 -0
- package/dist/condense.d.ts +6 -2
- package/dist/condense.js +85 -7
- package/dist/server.js +262 -25
- package/dist/tools/core/tools.js +15 -4
- package/dist/tools/types.d.ts +6 -2
- package/package.json +10 -4
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;
|
|
140
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/compat.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/condense.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
97
|
-
|
|
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(
|
|
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(
|
|
189
|
+
out.items = source.items.map(item => condenseEntity(entityName, item));
|
|
119
190
|
}
|
|
120
191
|
if (hasItem) {
|
|
121
|
-
out.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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
322
|
-
// older Planka (<= 1.26.x) route it replaced
|
|
323
|
-
// verified against a live
|
|
324
|
-
|
|
325
|
-
|
|
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]
|
|
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.
|
|
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
|
|
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.
|
package/dist/tools/core/tools.js
CHANGED
|
@@ -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",
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
20
|
-
|
|
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.
|
|
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
|
-
"
|
|
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",
|