@denisixnpm/planka-mcp 2.2.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/dist/server.js ADDED
@@ -0,0 +1,567 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
5
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
6
+ import { createServer } from "node:http";
7
+ import { createHash, timingSafeEqual } from "node:crypto";
8
+ // Import tool definitions
9
+ import { getEnabledTools, toolCounts } from "./tools/index.js";
10
+ import { condenseResult } from "./condense.js";
11
+ // ----- Configuration -----
12
+ const PLANKA_BASE_URL = process.env.PLANKA_BASE_URL || "http://localhost:3000";
13
+ const PLANKA_USERNAME = process.env.PLANKA_USERNAME;
14
+ const PLANKA_PASSWORD = process.env.PLANKA_PASSWORD;
15
+ const PLANKA_API_KEY = process.env.PLANKA_API_KEY?.trim();
16
+ const MCP_PORT = parseInt(process.env.MCP_PORT || "3001", 10);
17
+ const MCP_TRANSPORT = process.env.MCP_TRANSPORT || "stdio"; // "stdio" or "sse"
18
+ const MCP_HOST = process.env.MCP_HOST || "127.0.0.1";
19
+ const MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN?.trim();
20
+ const MCP_CORS_ORIGIN = process.env.MCP_CORS_ORIGIN?.trim();
21
+ const MAX_HTTP_RETRIES = parseInt(process.env.PLANKA_HTTP_MAX_RETRIES || "2", 10);
22
+ const RETRY_BASE_DELAY_MS = parseInt(process.env.PLANKA_HTTP_RETRY_BASE_DELAY_MS || "250", 10);
23
+ const PLANKA_HTTP_TIMEOUT_MS = parseInt(process.env.PLANKA_HTTP_TIMEOUT_MS || "30000", 10);
24
+ /** Condensed tool output for AI agents; set to "false" for raw Planka payloads. */
25
+ const PLANKA_CONDENSED_OUTPUT = process.env.PLANKA_CONDENSED_OUTPUT !== "false";
26
+ // Tool category configuration
27
+ const ENABLE_ALL_TOOLS = process.env.ENABLE_ALL_TOOLS === "true";
28
+ const ENABLE_ADMIN_TOOLS = process.env.ENABLE_ADMIN_TOOLS === "true";
29
+ const ENABLE_OPTIONAL_TOOLS = process.env.ENABLE_OPTIONAL_TOOLS === "true";
30
+ // ----- Token management -----
31
+ let cachedToken = null;
32
+ let tokenExpiry = 0;
33
+ let inflightLogin = null;
34
+ async function getAccessToken() {
35
+ // Return cached token if still valid (with 5 min buffer)
36
+ if (cachedToken && Date.now() < tokenExpiry - 5 * 60 * 1000) {
37
+ return cachedToken;
38
+ }
39
+ // Single-flight: concurrent callers with an expired token share one login request
40
+ inflightLogin ??= login().finally(() => {
41
+ inflightLogin = null;
42
+ });
43
+ return inflightLogin;
44
+ }
45
+ async function login() {
46
+ if (!PLANKA_USERNAME || !PLANKA_PASSWORD) {
47
+ throw new Error("Authentication is not configured. Set PLANKA_API_KEY or PLANKA_USERNAME and PLANKA_PASSWORD.");
48
+ }
49
+ const loginUrl = `${PLANKA_BASE_URL}/api/access-tokens`;
50
+ const res = await fetch(loginUrl, {
51
+ method: "POST",
52
+ headers: {
53
+ "Content-Type": "application/json",
54
+ "Accept": "application/json",
55
+ },
56
+ body: JSON.stringify({
57
+ emailOrUsername: PLANKA_USERNAME,
58
+ password: PLANKA_PASSWORD,
59
+ }),
60
+ signal: AbortSignal.timeout(PLANKA_HTTP_TIMEOUT_MS),
61
+ });
62
+ const text = await res.text();
63
+ if (!res.ok) {
64
+ throw new Error(`Authentication failed: ${res.status} - ${text}`);
65
+ }
66
+ const data = JSON.parse(text);
67
+ if (typeof data.item !== "string" || data.item.length === 0) {
68
+ throw new Error(`Authentication failed: unexpected login response - ${truncate(text, 200)}`);
69
+ }
70
+ const token = data.item;
71
+ cachedToken = token;
72
+ // Set expiry to 24 hours from now (Planka tokens typically last longer, but this is safe)
73
+ tokenExpiry = Date.now() + 24 * 60 * 60 * 1000;
74
+ return token;
75
+ }
76
+ // ----- Helper functions -----
77
+ function truncate(s, n) {
78
+ return s.length > n ? s.slice(0, n) + "…" : s;
79
+ }
80
+ function sleep(ms) {
81
+ const { promise, resolve } = Promise.withResolvers();
82
+ setTimeout(resolve, ms);
83
+ return promise;
84
+ }
85
+ function shouldRetryStatus(statusCode) {
86
+ return statusCode === 408 || statusCode === 429 || statusCode >= 500;
87
+ }
88
+ function formatAttempt(attempt) {
89
+ return `${attempt + 1}/${MAX_HTTP_RETRIES + 1}`;
90
+ }
91
+ /** Exponential backoff delay for retry attempt N (0-based). */
92
+ function retryDelayMs(attempt) {
93
+ return RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
94
+ }
95
+ /** Send a JSON HTTP response (single writer for every SSE endpoint). */
96
+ function sendJson(res, status, payload) {
97
+ res.writeHead(status, { "Content-Type": "application/json" });
98
+ res.end(JSON.stringify(payload));
99
+ }
100
+ function buildTools() {
101
+ const enabledTools = getEnabledTools({
102
+ enableAllTools: ENABLE_ALL_TOOLS,
103
+ enableAdminTools: ENABLE_ADMIN_TOOLS,
104
+ enableOptionalTools: ENABLE_OPTIONAL_TOOLS,
105
+ });
106
+ return enabledTools.map((def) => ({
107
+ tool: {
108
+ name: def.name,
109
+ description: def.description,
110
+ inputSchema: def.inputSchema,
111
+ },
112
+ groupedDef: def,
113
+ }));
114
+ }
115
+ // ----- Execute API call for grouped tools -----
116
+ /**
117
+ * Planka <= 1.26.x routes that v2 renamed, verified against a live 1.26.2
118
+ * instance. When the v2 route answers 404, the call is retried once with the
119
+ * legacy route so a single server build serves both API generations.
120
+ */
121
+ const LEGACY_PATH_FALLBACKS = {
122
+ "/boards/{boardId}/board-memberships": "/boards/{boardId}/memberships",
123
+ "/projects/{projectId}/project-managers": "/projects/{projectId}/managers",
124
+ "/cards/{cardId}/card-labels": "/cards/{cardId}/labels",
125
+ "/cards/{cardId}/card-labels/labelId:{labelId}": "/cards/{cardId}/labels/{labelId}",
126
+ "/cards/{cardId}/card-memberships": "/cards/{cardId}/memberships",
127
+ "/cards/{cardId}/card-memberships/userId:{userId}": "/cards/{cardId}/memberships?userId={userId}",
128
+ "/cards/{cardId}/custom-field-values/customFieldGroupId:{customFieldGroupId}:customFieldId:{customFieldId}": "/cards/{cardId}/custom-field-values/customFieldGroupId:{customFieldGroupId}:customFieldId:${customFieldId}",
129
+ };
130
+ async function executeGroupedApiCall(groupedDef, input, overridePath, retryAttempt = 0, unauthorizedRetried = false, allowLegacyPathFallback = true, scope) {
131
+ try {
132
+ const action = input?.action;
133
+ if (!action) {
134
+ return { success: false, error: "Missing required 'action' parameter" };
135
+ }
136
+ const operation = groupedDef.operations[action];
137
+ if (!operation) {
138
+ const validActions = Object.keys(groupedDef.operations).join(", ");
139
+ return { success: false, error: `Invalid action '${action}'. Valid actions: ${validActions}` };
140
+ }
141
+ // Construct URL with path parameters
142
+ const canonicalPath = overridePath ?? operation.path;
143
+ let actualPath = canonicalPath;
144
+ // Replace path parameters: an explicit value in `data` always wins over
145
+ // the generic `id` fallback (e.g. data.customFieldId must not become the
146
+ // card id when both are present in a composite path).
147
+ const pathParams = actualPath.match(/\{(\w+)\}/g) || [];
148
+ for (const param of pathParams) {
149
+ const paramName = param.slice(1, -1); // Remove { and }
150
+ const explicit = input.data?.[paramName];
151
+ let value;
152
+ if (explicit !== undefined && explicit !== null && explicit !== "") {
153
+ value = explicit;
154
+ }
155
+ else if (input.id && ["id", "projectId", "boardId", "listId", "cardId", "userId", "taskListId", "baseCustomFieldGroupId", "customFieldGroupId", "customFieldId"].includes(paramName)) {
156
+ value = input.id;
157
+ }
158
+ else if (scope) {
159
+ // Working scope selected via the `context` tool: the scoped id
160
+ // fills parameters the call did not provide.
161
+ value = scope[paramName];
162
+ }
163
+ if (value) {
164
+ actualPath = actualPath.replace(param, encodeURIComponent(String(value)));
165
+ }
166
+ }
167
+ // Check if there are still unresolved parameters
168
+ const unresolvedParams = actualPath.match(/\{(\w+)\}/g);
169
+ if (unresolvedParams) {
170
+ return {
171
+ success: false,
172
+ error: `Missing required path parameters: ${unresolvedParams.join(", ")}. Provide them via 'id', 'data', or the 'context' tool.`
173
+ };
174
+ }
175
+ const url = new URL(`${PLANKA_BASE_URL}/api${actualPath}`);
176
+ // Add query parameters
177
+ if (input?.query) {
178
+ for (const [k, v] of Object.entries(input.query)) {
179
+ if (Array.isArray(v)) {
180
+ v.forEach(val => url.searchParams.append(k, String(val)));
181
+ }
182
+ else if (v !== undefined && v !== null) {
183
+ url.searchParams.set(k, String(v));
184
+ }
185
+ }
186
+ }
187
+ const methodUpper = operation.method;
188
+ const headers = {
189
+ "Accept": "application/json",
190
+ };
191
+ // Add authentication header if required (default to true)
192
+ const requiresAuth = operation.requiresAuth !== false;
193
+ if (requiresAuth) {
194
+ if (PLANKA_API_KEY) {
195
+ headers["X-Api-Key"] = PLANKA_API_KEY;
196
+ }
197
+ else {
198
+ const token = await getAccessToken();
199
+ headers["Authorization"] = `Bearer ${token}`;
200
+ }
201
+ }
202
+ // Handle request body
203
+ let body = undefined;
204
+ if (["POST", "PUT", "PATCH"].includes(methodUpper) && input?.data !== undefined) {
205
+ headers["Content-Type"] = "application/json";
206
+ body = JSON.stringify(input.data);
207
+ }
208
+ let res;
209
+ try {
210
+ res = await fetch(url, { method: methodUpper, headers, body, signal: AbortSignal.timeout(PLANKA_HTTP_TIMEOUT_MS) });
211
+ }
212
+ catch (requestError) {
213
+ if (retryAttempt < MAX_HTTP_RETRIES) {
214
+ const delayMs = retryDelayMs(retryAttempt);
215
+ console.error(`[retry] network error for ${groupedDef.name}.${action} (${methodUpper} ${actualPath}), attempt ${formatAttempt(retryAttempt)} failed, retrying in ${delayMs}ms: ${requestError instanceof Error ? requestError.message : String(requestError)}`);
216
+ await sleep(delayMs);
217
+ return executeGroupedApiCall(groupedDef, input, overridePath, retryAttempt + 1, unauthorizedRetried, allowLegacyPathFallback, scope);
218
+ }
219
+ throw requestError;
220
+ }
221
+ const contentType = res.headers.get("content-type") ?? "";
222
+ const text = await res.text();
223
+ let data = text;
224
+ if (contentType.includes("application/json") && text) {
225
+ try {
226
+ data = JSON.parse(text);
227
+ }
228
+ catch {
229
+ // Keep as text if JSON parsing fails
230
+ }
231
+ }
232
+ if (res.ok) {
233
+ return { success: true, data };
234
+ }
235
+ else {
236
+ // If we get 401 with bearer auth, clear the cached token and retry once.
237
+ // API key auth cannot be refreshed in-process.
238
+ if (res.status === 401 && requiresAuth && !PLANKA_API_KEY && cachedToken && !unauthorizedRetried) {
239
+ cachedToken = null;
240
+ tokenExpiry = 0;
241
+ console.error(`[auth] received 401 for ${groupedDef.name}.${action} (${methodUpper} ${actualPath}); clearing cached token and retrying once`);
242
+ return executeGroupedApiCall(groupedDef, input, overridePath, retryAttempt, true, allowLegacyPathFallback, scope);
243
+ }
244
+ // Legacy-version fallback: when a v2 route 404s, retry once with the
245
+ // older Planka (<= 1.26.x) route it replaced. All variants below are
246
+ // verified against a live Planka 1.26.2 instance.
247
+ if (res.status === 404 && allowLegacyPathFallback) {
248
+ const legacyPath = LEGACY_PATH_FALLBACKS[canonicalPath];
249
+ if (legacyPath && legacyPath !== canonicalPath) {
250
+ console.error(`[compat] 404 for ${groupedDef.name}.${action} (${methodUpper} ${actualPath}); retrying with legacy route ${legacyPath}`);
251
+ return executeGroupedApiCall(groupedDef, input, legacyPath, retryAttempt, unauthorizedRetried, false, scope);
252
+ }
253
+ }
254
+ if (shouldRetryStatus(res.status) && retryAttempt < MAX_HTTP_RETRIES) {
255
+ const delayMs = retryDelayMs(retryAttempt);
256
+ console.error(`[retry] transient HTTP ${res.status} for ${groupedDef.name}.${action} (${methodUpper} ${actualPath}), attempt ${formatAttempt(retryAttempt)} failed, retrying in ${delayMs}ms`);
257
+ await sleep(delayMs);
258
+ return executeGroupedApiCall(groupedDef, input, overridePath, retryAttempt + 1, unauthorizedRetried, allowLegacyPathFallback, scope);
259
+ }
260
+ console.error(`[error] API call failed for ${groupedDef.name}.${action} (${methodUpper} ${actualPath}) with HTTP ${res.status}`);
261
+ return {
262
+ success: false,
263
+ error: `HTTP ${res.status}: ${truncate(typeof data === 'string' ? data : JSON.stringify(data), 2000)}`,
264
+ };
265
+ }
266
+ }
267
+ catch (error) {
268
+ console.error(`[error] API call crashed for ${groupedDef.name}.${input?.action ?? "unknown"}: ${error instanceof Error ? error.message : String(error)}`);
269
+ return {
270
+ success: false,
271
+ error: error instanceof Error ? error.message : String(error),
272
+ };
273
+ }
274
+ }
275
+ /**
276
+ * Local handler for the `context` tool: maintains the working scope of this
277
+ * server instance (per SSE session, process-wide for stdio). Enriches `set`
278
+ * with the immediate contents of the selected level so the agent can pick
279
+ * the next id without an extra round trip.
280
+ */
281
+ async function handleContextCall(input, scope) {
282
+ const action = input?.action;
283
+ if (action === "get") {
284
+ return { success: true, data: { scope } };
285
+ }
286
+ if (action === "clear") {
287
+ for (const key of Object.keys(scope))
288
+ delete scope[key];
289
+ return { success: true, data: { scope } };
290
+ }
291
+ if (action !== "set") {
292
+ return { success: false, error: "Invalid action. Valid actions: set, get, clear" };
293
+ }
294
+ const data = input?.data ?? {};
295
+ const setting = (key) => typeof data[key] === "string" && data[key] !== "";
296
+ // Narrowing: a higher level resets the deeper ones.
297
+ if (setting("projectId")) {
298
+ delete scope.boardId;
299
+ delete scope.listId;
300
+ delete scope.cardId;
301
+ }
302
+ if (setting("boardId")) {
303
+ delete scope.listId;
304
+ delete scope.cardId;
305
+ }
306
+ if (setting("listId")) {
307
+ delete scope.cardId;
308
+ }
309
+ for (const key of ["projectId", "boardId", "listId", "cardId"]) {
310
+ if (setting(key))
311
+ scope[key] = data[key];
312
+ }
313
+ // One free round trip: opening a level shows its contents (boards of the
314
+ // project via included.boards; lists/cards of the board via included).
315
+ const overview = {};
316
+ if (scope.boardId) {
317
+ const boardsTool = toolMap.get("boards");
318
+ const res = boardsTool ? await executeGroupedApiCall(boardsTool.groupedDef, { action: "get", id: scope.boardId }) : null;
319
+ if (res?.success)
320
+ overview.board = condenseResult("boards", res.data);
321
+ }
322
+ else if (scope.projectId) {
323
+ const projectsTool = toolMap.get("projects");
324
+ const res = projectsTool ? await executeGroupedApiCall(projectsTool.groupedDef, { action: "get", id: scope.projectId }) : null;
325
+ if (res?.success)
326
+ overview.project = condenseResult("projects", res.data);
327
+ }
328
+ return { success: true, data: { scope, overview } };
329
+ }
330
+ // ----- Initialize MCP Server -----
331
+ const toolDefinitions = buildTools();
332
+ const toolMap = new Map(toolDefinitions.map(t => [t.tool.name, t]));
333
+ // Log tool configuration (suppressed in --healthcheck mode to keep probe output clean)
334
+ if (!process.argv.includes("--healthcheck")) {
335
+ console.error(`Tool configuration (grouped):`);
336
+ console.error(` - Core: ${toolCounts.core} tools (${toolCounts.coreOperations} operations)`);
337
+ console.error(` - Admin: ${toolCounts.admin} tools (${toolCounts.adminOperations} operations) - ${ENABLE_ADMIN_TOOLS ? "enabled" : "disabled"}`);
338
+ console.error(` - Optional: ${toolCounts.optional} tools (${toolCounts.optionalOperations} operations) - ${ENABLE_OPTIONAL_TOOLS ? "enabled" : "disabled"}`);
339
+ console.error(` - Total available: ${toolCounts.total} tools (${toolCounts.totalOperations} operations)`);
340
+ console.error(` - Currently enabled: ${toolDefinitions.length} tools`);
341
+ }
342
+ function createMcpServer() {
343
+ const server = new McpServer({
344
+ name: "planka-mcp",
345
+ version: "2.2.0",
346
+ }, {
347
+ capabilities: {
348
+ tools: {},
349
+ },
350
+ });
351
+ // Working scope of this server instance: one per SSE session, process-wide for stdio.
352
+ const scope = {};
353
+ // Handle list tools request
354
+ server.server.setRequestHandler(ListToolsRequestSchema, async () => {
355
+ return {
356
+ tools: toolDefinitions.map(t => t.tool),
357
+ };
358
+ });
359
+ // Handle tool execution
360
+ server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
361
+ const { name, arguments: args } = request.params;
362
+ const toolDef = toolMap.get(name);
363
+ if (!toolDef) {
364
+ return {
365
+ content: [{ type: "text", text: `Unknown tool: ${name}` }],
366
+ isError: true,
367
+ };
368
+ }
369
+ const result = toolDef.groupedDef.name === "context"
370
+ ? await handleContextCall(args, scope)
371
+ : await executeGroupedApiCall(toolDef.groupedDef, args, undefined, 0, false, true, scope);
372
+ if (result.success) {
373
+ const wantsRaw = !PLANKA_CONDENSED_OUTPUT || args?.raw === true;
374
+ const payload = wantsRaw ? result.data : condenseResult(toolDef.groupedDef.name, result.data);
375
+ return {
376
+ content: [
377
+ {
378
+ type: "text",
379
+ text: typeof payload === "string" ? payload : JSON.stringify(payload, null, 2),
380
+ },
381
+ ],
382
+ };
383
+ }
384
+ else {
385
+ return {
386
+ content: [{ type: "text", text: result.error || "Unknown error" }],
387
+ isError: true,
388
+ };
389
+ }
390
+ });
391
+ return server;
392
+ }
393
+ // ----- SSE security -----
394
+ const LOOPBACK_HOSTS = { "127.0.0.1": true, localhost: true, "::1": true };
395
+ /**
396
+ * Bearer-token check for SSE endpoints. Always enabled when MCP_AUTH_TOKEN is
397
+ * set; compares SHA-256 digests so timingSafeEqual never sees unequal lengths.
398
+ */
399
+ function isAuthorized(req) {
400
+ if (!MCP_AUTH_TOKEN) {
401
+ return true;
402
+ }
403
+ const header = req.headers.authorization || "";
404
+ const provided = createHash("sha256").update(header).digest();
405
+ const expected = createHash("sha256").update(`Bearer ${MCP_AUTH_TOKEN}`).digest();
406
+ return timingSafeEqual(provided, expected);
407
+ }
408
+ // ----- Start the server -----
409
+ async function main() {
410
+ if (MCP_TRANSPORT === "sse") {
411
+ // SSE transport - supports multiple clients over HTTP
412
+ const activeTransports = new Map();
413
+ const heartbeatIntervals = new Map();
414
+ // Heartbeat interval in milliseconds (30 seconds to prevent proxy/load
415
+ // balancer timeouts); configurable for deployments behind aggressive proxies.
416
+ const HEARTBEAT_INTERVAL = parseInt(process.env.MCP_HEARTBEAT_INTERVAL_MS || "30000", 10);
417
+ const httpServer = createServer(async (req, res) => {
418
+ // CORS: only enabled when MCP_CORS_ORIGIN is explicitly set — no wildcard by default
419
+ if (MCP_CORS_ORIGIN) {
420
+ res.setHeader("Access-Control-Allow-Origin", MCP_CORS_ORIGIN);
421
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
422
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
423
+ if (req.method === "OPTIONS") {
424
+ res.writeHead(204);
425
+ res.end();
426
+ return;
427
+ }
428
+ }
429
+ const url = new URL(req.url || "/", `http://${req.headers.host}`);
430
+ if (url.pathname === "/sse" && req.method === "GET") {
431
+ if (!isAuthorized(req)) {
432
+ sendJson(res, 401, { error: "Unauthorized" });
433
+ return;
434
+ }
435
+ // New SSE connection - create a new server instance for this client
436
+ const mcpServer = createMcpServer();
437
+ const transport = new SSEServerTransport("/messages", res);
438
+ const sessionId = transport.sessionId;
439
+ activeTransports.set(sessionId, transport);
440
+ console.error(`Client connected: ${sessionId} (${activeTransports.size} active clients)`);
441
+ // Set up heartbeat to keep connection alive
442
+ const heartbeat = setInterval(() => {
443
+ try {
444
+ // Send SSE comment as heartbeat (doesn't affect protocol)
445
+ if (!res.writableEnded) {
446
+ res.write(": heartbeat\n\n");
447
+ }
448
+ }
449
+ catch (err) {
450
+ console.error(`Heartbeat failed for ${sessionId}:`, err);
451
+ clearInterval(heartbeat);
452
+ heartbeatIntervals.delete(sessionId);
453
+ }
454
+ }, HEARTBEAT_INTERVAL);
455
+ heartbeatIntervals.set(sessionId, heartbeat);
456
+ // Handle connection close
457
+ const cleanup = () => {
458
+ const hb = heartbeatIntervals.get(sessionId);
459
+ if (hb) {
460
+ clearInterval(hb);
461
+ heartbeatIntervals.delete(sessionId);
462
+ }
463
+ activeTransports.delete(sessionId);
464
+ console.error(`Client disconnected: ${sessionId} (${activeTransports.size} active clients)`);
465
+ };
466
+ transport.onclose = cleanup;
467
+ // Also handle HTTP connection close events
468
+ res.on("close", cleanup);
469
+ res.on("error", (err) => {
470
+ console.error(`SSE stream error for ${sessionId}:`, err);
471
+ cleanup();
472
+ });
473
+ try {
474
+ await mcpServer.server.connect(transport);
475
+ }
476
+ catch (err) {
477
+ console.error(`Failed to connect transport for ${sessionId}:`, err);
478
+ cleanup();
479
+ }
480
+ }
481
+ else if (url.pathname === "/messages" && req.method === "POST") {
482
+ if (!isAuthorized(req)) {
483
+ sendJson(res, 401, { error: "Unauthorized" });
484
+ return;
485
+ }
486
+ // Handle messages for existing SSE connection
487
+ const sessionId = url.searchParams.get("sessionId");
488
+ if (!sessionId) {
489
+ sendJson(res, 400, { error: "Missing sessionId parameter" });
490
+ return;
491
+ }
492
+ const transport = activeTransports.get(sessionId);
493
+ if (!transport) {
494
+ sendJson(res, 404, { error: "Session not found" });
495
+ return;
496
+ }
497
+ try {
498
+ await transport.handlePostMessage(req, res);
499
+ }
500
+ catch (err) {
501
+ console.error(`Error handling message for ${sessionId}:`, err);
502
+ sendJson(res, 500, { error: "Internal server error" });
503
+ }
504
+ }
505
+ else if (url.pathname === "/health") {
506
+ // Health check endpoint
507
+ sendJson(res, 200, {
508
+ status: "ok",
509
+ activeClients: activeTransports.size,
510
+ toolsAvailable: toolDefinitions.length,
511
+ toolCounts: {
512
+ core: { tools: toolCounts.core, operations: toolCounts.coreOperations },
513
+ admin: ENABLE_ADMIN_TOOLS ? { tools: toolCounts.admin, operations: toolCounts.adminOperations } : { tools: 0, operations: 0 },
514
+ optional: ENABLE_OPTIONAL_TOOLS ? { tools: toolCounts.optional, operations: toolCounts.optionalOperations } : { tools: 0, operations: 0 },
515
+ },
516
+ });
517
+ }
518
+ else {
519
+ sendJson(res, 404, { error: "Not found" });
520
+ }
521
+ });
522
+ // Set keep-alive timeout to prevent premature connection closure
523
+ httpServer.keepAliveTimeout = 120000; // 2 minutes
524
+ httpServer.headersTimeout = 125000; // Slightly higher than keepAliveTimeout
525
+ if (!LOOPBACK_HOSTS[MCP_HOST] && !MCP_AUTH_TOKEN) {
526
+ console.error(`[security] SSE server bound to ${MCP_HOST} without MCP_AUTH_TOKEN - anyone who can reach this port has full tool access`);
527
+ }
528
+ httpServer.listen(MCP_PORT, MCP_HOST, () => {
529
+ console.error(`Planka MCP server (SSE) listening on http://${MCP_HOST}:${MCP_PORT}`);
530
+ console.error(` - SSE endpoint: http://${MCP_HOST}:${MCP_PORT}/sse`);
531
+ console.error(` - Messages endpoint: http://${MCP_HOST}:${MCP_PORT}/messages`);
532
+ console.error(` - Health check: http://${MCP_HOST}:${MCP_PORT}/health`);
533
+ console.error(` - ${toolDefinitions.length} tools available`);
534
+ console.error(` - Heartbeat interval: ${HEARTBEAT_INTERVAL / 1000}s`);
535
+ if (MCP_AUTH_TOKEN) {
536
+ console.error(" - Auth: bearer token required (MCP_AUTH_TOKEN)");
537
+ }
538
+ });
539
+ }
540
+ else {
541
+ // Stdio transport - single client mode
542
+ const mcpServer = createMcpServer();
543
+ const transport = new StdioServerTransport();
544
+ await mcpServer.server.connect(transport);
545
+ console.error(`Planka MCP server started (stdio mode, ${toolDefinitions.length} tools available)`);
546
+ }
547
+ }
548
+ /**
549
+ * Standalone health probe for container healthchecks: exits 0 when the SSE
550
+ * server answers /health, 1 otherwise. Usable from scratch/distroless images
551
+ * that have no shell or wget.
552
+ */
553
+ async function runHealthcheck() {
554
+ const host = LOOPBACK_HOSTS[MCP_HOST] ? MCP_HOST : "127.0.0.1";
555
+ try {
556
+ const res = await fetch(`http://${host}:${MCP_PORT}/health`, { signal: AbortSignal.timeout(3000) });
557
+ process.exit(res.ok ? 0 : 1);
558
+ }
559
+ catch {
560
+ process.exit(1);
561
+ }
562
+ }
563
+ const entry = process.argv.includes("--healthcheck") ? runHealthcheck() : main();
564
+ entry.catch((error) => {
565
+ console.error("Fatal error:", error);
566
+ process.exit(1);
567
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Admin tools - Require admin privileges
3
+ */
4
+ import { GroupedToolDefinition } from "../types.js";
5
+ import { configTool, usersTool, webhooksTool, projectManagersTool } from "./tools.js";
6
+ export { configTool, usersTool, webhooksTool, projectManagersTool, };
7
+ /**
8
+ * All admin tools combined
9
+ */
10
+ export declare const adminTools: GroupedToolDefinition[];
@@ -0,0 +1,11 @@
1
+ import { configTool, usersTool, webhooksTool, projectManagersTool, } from "./tools.js";
2
+ export { configTool, usersTool, webhooksTool, projectManagersTool, };
3
+ /**
4
+ * All admin tools combined
5
+ */
6
+ export const adminTools = [
7
+ configTool,
8
+ usersTool,
9
+ webhooksTool,
10
+ projectManagersTool,
11
+ ];
@@ -0,0 +1,17 @@
1
+ import { GroupedToolDefinition } from "../types.js";
2
+ /**
3
+ * Config tool - manages application configuration (admin only)
4
+ */
5
+ export declare const configTool: GroupedToolDefinition;
6
+ /**
7
+ * Users tool - manages user accounts (admin only)
8
+ */
9
+ export declare const usersTool: GroupedToolDefinition;
10
+ /**
11
+ * Webhooks tool - manages webhooks (admin only)
12
+ */
13
+ export declare const webhooksTool: GroupedToolDefinition;
14
+ /**
15
+ * Project Managers tool - manages project manager assignments
16
+ */
17
+ export declare const projectManagersTool: GroupedToolDefinition;