@masonator/coolify-mcp 2.6.5 → 2.6.6

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.
@@ -148,41 +148,46 @@ describe('CoolifyMcpServer v2', () => {
148
148
  });
149
149
  });
150
150
  describe('truncateLogs', () => {
151
+ // Plain text log tests
151
152
  it('should return logs unchanged when within limits', () => {
152
153
  const logs = 'line1\nline2\nline3';
153
154
  const result = truncateLogs(logs, 200, 50000);
154
- expect(result).toBe(logs);
155
+ expect(result.logs).toBe(logs);
156
+ expect(result.total).toBe(3);
155
157
  });
156
158
  it('should truncate to last N lines', () => {
157
159
  const logs = 'line1\nline2\nline3\nline4\nline5';
158
160
  const result = truncateLogs(logs, 3, 50000);
159
- expect(result).toBe('line3\nline4\nline5');
161
+ expect(result.logs).toBe('line3\nline4\nline5');
162
+ expect(result.total).toBe(5);
163
+ expect(result.showing_start).toBe(3);
164
+ expect(result.showing_end).toBe(5);
160
165
  });
161
166
  it('should truncate by character limit when lines are huge', () => {
162
167
  const hugeLine = 'x'.repeat(100);
163
168
  const logs = `${hugeLine}\n${hugeLine}\n${hugeLine}`;
164
169
  const result = truncateLogs(logs, 200, 50);
165
- expect(result.length).toBeLessThanOrEqual(50);
166
- expect(result.startsWith('...[truncated]...')).toBe(true);
170
+ expect(result.logs.length).toBeLessThanOrEqual(50);
171
+ expect(result.logs.startsWith('...[truncated]...')).toBe(true);
167
172
  });
168
173
  it('should not add truncation prefix when under char limit', () => {
169
174
  const logs = 'line1\nline2\nline3';
170
175
  const result = truncateLogs(logs, 200, 50000);
171
- expect(result.startsWith('...[truncated]...')).toBe(false);
176
+ expect(result.logs.startsWith('...[truncated]...')).toBe(false);
172
177
  });
173
178
  it('should handle empty logs', () => {
174
179
  const result = truncateLogs('', 200, 50000);
175
- expect(result).toBe('');
180
+ expect(result.logs).toBe('');
176
181
  });
177
182
  it('should use default limits when not specified', () => {
178
183
  const logs = 'line1\nline2';
179
184
  const result = truncateLogs(logs);
180
- expect(result).toBe(logs);
185
+ expect(result.logs).toBe(logs);
181
186
  });
182
187
  it('should respect custom line limit', () => {
183
188
  const lines = Array.from({ length: 300 }, (_, i) => `line${i + 1}`).join('\n');
184
189
  const result = truncateLogs(lines, 50, 50000);
185
- const resultLines = result.split('\n');
190
+ const resultLines = result.logs.split('\n');
186
191
  expect(resultLines.length).toBe(50);
187
192
  expect(resultLines[0]).toBe('line251');
188
193
  expect(resultLines[49]).toBe('line300');
@@ -190,7 +195,90 @@ describe('truncateLogs', () => {
190
195
  it('should respect custom char limit', () => {
191
196
  const logs = 'x'.repeat(1000);
192
197
  const result = truncateLogs(logs, 200, 100);
193
- expect(result.length).toBe(100);
198
+ expect(result.logs.length).toBe(100);
199
+ });
200
+ // Pagination tests (plain text)
201
+ it('should paginate plain text logs (page 2 = older entries)', () => {
202
+ const logs = Array.from({ length: 30 }, (_, i) => `line${i + 1}`).join('\n');
203
+ const page1 = truncateLogs(logs, 10, 50000, 1);
204
+ const page2 = truncateLogs(logs, 10, 50000, 2);
205
+ const page3 = truncateLogs(logs, 10, 50000, 3);
206
+ expect(page1.logs).toContain('line30');
207
+ expect(page1.logs).toContain('line21');
208
+ expect(page1.logs).not.toContain('line20');
209
+ expect(page2.logs).toContain('line20');
210
+ expect(page2.logs).toContain('line11');
211
+ expect(page2.logs).not.toContain('line10');
212
+ expect(page3.logs).toContain('line10');
213
+ expect(page3.logs).toContain('line1');
214
+ expect(page1.showing_start).toBe(21);
215
+ expect(page1.showing_end).toBe(30);
216
+ });
217
+ // JSON array format tests (Coolify deployment logs)
218
+ it('should parse JSON array logs and return last N visible entries', () => {
219
+ const entries = [
220
+ { output: 'Building...', timestamp: '2026-01-01T00:00:01Z', hidden: false },
221
+ { output: 'docker pull', timestamp: '2026-01-01T00:00:02Z', hidden: true },
222
+ { output: 'Compiling...', timestamp: '2026-01-01T00:00:03Z', hidden: false },
223
+ { output: 'Done.', timestamp: '2026-01-01T00:00:04Z', hidden: false },
224
+ ];
225
+ const result = truncateLogs(JSON.stringify(entries), 2, 50000);
226
+ expect(result.logs).toContain('Compiling...');
227
+ expect(result.logs).toContain('Done.');
228
+ expect(result.logs).not.toContain('Building...');
229
+ expect(result.logs).not.toContain('docker pull');
230
+ expect(result.total).toBe(3); // 3 visible entries
231
+ });
232
+ it('should filter hidden entries from JSON logs', () => {
233
+ const entries = [
234
+ { output: 'visible1', timestamp: '2026-01-01T00:00:01Z', hidden: false },
235
+ { output: 'hidden1', timestamp: '2026-01-01T00:00:02Z', hidden: true },
236
+ { output: 'hidden2', timestamp: '2026-01-01T00:00:03Z', hidden: true },
237
+ { output: 'visible2', timestamp: '2026-01-01T00:00:04Z', hidden: false },
238
+ ];
239
+ const result = truncateLogs(JSON.stringify(entries), 200, 50000);
240
+ expect(result.logs).toContain('visible1');
241
+ expect(result.logs).toContain('visible2');
242
+ expect(result.logs).not.toContain('hidden1');
243
+ expect(result.logs).not.toContain('hidden2');
244
+ });
245
+ it('should format JSON log entries with timestamp and output', () => {
246
+ const entries = [
247
+ { output: 'Starting deploy', timestamp: '2026-01-01T10:00:00Z', hidden: false },
248
+ ];
249
+ const result = truncateLogs(JSON.stringify(entries), 200, 50000);
250
+ expect(result.logs).toBe('[2026-01-01T10:00:00Z] Starting deploy');
251
+ });
252
+ it('should paginate JSON logs (page 2 = older entries)', () => {
253
+ const entries = Array.from({ length: 30 }, (_, i) => ({
254
+ output: `step ${i + 1}`,
255
+ timestamp: `2026-01-01T00:00:${String(i).padStart(2, '0')}Z`,
256
+ hidden: false,
257
+ }));
258
+ const page1 = truncateLogs(JSON.stringify(entries), 10, 50000, 1);
259
+ const page2 = truncateLogs(JSON.stringify(entries), 10, 50000, 2);
260
+ expect(page1.logs).toContain('step 30');
261
+ expect(page1.logs).toContain('step 21');
262
+ expect(page1.logs).not.toContain('step 20');
263
+ expect(page2.logs).toContain('step 20');
264
+ expect(page2.logs).toContain('step 11');
265
+ expect(page2.logs).not.toContain('step 10');
266
+ expect(page1.total).toBe(30);
267
+ expect(page1.showing_start).toBe(21);
268
+ expect(page1.showing_end).toBe(30);
269
+ expect(page2.showing_start).toBe(11);
270
+ expect(page2.showing_end).toBe(20);
271
+ });
272
+ it('should return metadata with total and showing range', () => {
273
+ const entries = Array.from({ length: 50 }, (_, i) => ({
274
+ output: `step ${i}`,
275
+ timestamp: `2026-01-01T00:00:${String(i).padStart(2, '0')}Z`,
276
+ hidden: false,
277
+ }));
278
+ const result = truncateLogs(JSON.stringify(entries), 10, 50000);
279
+ expect(result.total).toBe(50);
280
+ expect(result.showing_start).toBe(41);
281
+ expect(result.showing_end).toBe(50);
194
282
  });
195
283
  });
196
284
  // =============================================================================
@@ -6,11 +6,19 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
7
7
  import type { CoolifyConfig, ResponseAction, ResponsePagination } from '../types/coolify.js';
8
8
  export declare const VERSION: string;
9
+ export interface TruncatedLogsResult {
10
+ logs: string;
11
+ total: number;
12
+ showing_start: number;
13
+ showing_end: number;
14
+ }
9
15
  /**
10
- * Truncate logs by line count and character count.
16
+ * Truncate logs by entry count with pagination support.
17
+ * Handles both JSON array format (Coolify deployment logs) and plain text.
18
+ * Page 1 = most recent entries, page 2 = next older batch, etc.
11
19
  * Exported for testing.
12
20
  */
13
- export declare function truncateLogs(logs: string, lineLimit?: number, charLimit?: number): string;
21
+ export declare function truncateLogs(logs: string, lineLimit?: number, charLimit?: number, page?: number): TruncatedLogsResult;
14
22
  /** Generate contextual actions for an application based on its status */
15
23
  export declare function getApplicationActions(uuid: string, status?: string): ResponseAction[];
16
24
  /** Generate contextual actions for a deployment */
@@ -25,21 +25,55 @@ function wrap(fn) {
25
25
  }
26
26
  const TRUNCATION_PREFIX = '...[truncated]...\n';
27
27
  /**
28
- * Truncate logs by line count and character count.
28
+ * Truncate logs by entry count with pagination support.
29
+ * Handles both JSON array format (Coolify deployment logs) and plain text.
30
+ * Page 1 = most recent entries, page 2 = next older batch, etc.
29
31
  * Exported for testing.
30
32
  */
31
- export function truncateLogs(logs, lineLimit = 200, charLimit = 50000) {
32
- // First: limit by lines
33
- const logLines = logs.split('\n');
34
- const limitedLines = logLines.slice(-lineLimit);
35
- let truncatedLogs = limitedLines.join('\n');
36
- // Second: limit by characters (safety net for huge lines)
37
- if (truncatedLogs.length > charLimit) {
38
- // Account for prefix length to stay within limit
33
+ export function truncateLogs(logs, lineLimit = 200, charLimit = 50000, page = 1) {
34
+ // Try parsing as JSON array (Coolify deployment log format)
35
+ let lines;
36
+ let total;
37
+ try {
38
+ const entries = JSON.parse(logs);
39
+ if (Array.isArray(entries)) {
40
+ const visible = entries.filter((e) => !e.hidden);
41
+ total = visible.length;
42
+ const end = total - (page - 1) * lineLimit;
43
+ const start = Math.max(0, end - lineLimit);
44
+ const slice = visible.slice(start, end);
45
+ lines = slice.map((e) => `[${e.timestamp ?? ''}] ${e.output ?? ''}`);
46
+ }
47
+ else {
48
+ const allLines = logs.split('\n');
49
+ total = allLines.length;
50
+ const end = total - (page - 1) * lineLimit;
51
+ const start = Math.max(0, end - lineLimit);
52
+ lines = allLines.slice(start, end);
53
+ }
54
+ }
55
+ catch {
56
+ // Plain text logs — split by newlines
57
+ const allLines = logs.split('\n');
58
+ total = allLines.length;
59
+ const end = total - (page - 1) * lineLimit;
60
+ const start = Math.max(0, end - lineLimit);
61
+ lines = allLines.slice(start, end);
62
+ }
63
+ const end = total - (page - 1) * lineLimit;
64
+ const start = Math.max(0, end - lineLimit);
65
+ let result = lines.join('\n');
66
+ // Safety net: limit by characters
67
+ if (result.length > charLimit) {
39
68
  const prefixLen = TRUNCATION_PREFIX.length;
40
- truncatedLogs = TRUNCATION_PREFIX + truncatedLogs.slice(-(charLimit - prefixLen));
69
+ result = TRUNCATION_PREFIX + result.slice(-(charLimit - prefixLen));
41
70
  }
42
- return truncatedLogs;
71
+ return {
72
+ logs: result,
73
+ total,
74
+ showing_start: start + 1,
75
+ showing_end: Math.min(end, total),
76
+ };
43
77
  }
44
78
  // =============================================================================
45
79
  // Action Generators for HATEOAS-style responses
@@ -676,22 +710,48 @@ export class CoolifyMcpServer extends McpServer {
676
710
  this.tool('deployment', 'Manage deployment: get/cancel/list_for_app (logs excluded by default, use lines param to include)', {
677
711
  action: z.enum(['get', 'cancel', 'list_for_app']),
678
712
  uuid: z.string(),
679
- lines: z.number().optional(), // Include logs truncated to last N lines (omit for no logs)
713
+ lines: z.number().optional(), // Include logs truncated to last N entries (omit for no logs)
714
+ page: z.number().optional(), // Log page (1=most recent, 2=older, etc.)
680
715
  max_chars: z.number().optional(), // Limit log output to last N chars (default: 50000)
681
- }, async ({ action, uuid, lines, max_chars }) => {
716
+ }, async ({ action, uuid, lines, page, max_chars }) => {
682
717
  switch (action) {
683
718
  case 'get':
684
719
  // If lines param specified, include logs and truncate
685
720
  if (lines !== undefined) {
721
+ const p = page ?? 1;
722
+ const ll = lines;
686
723
  return wrapWithActions(async () => {
687
724
  const deployment = (await this.client.getDeployment(uuid, {
688
725
  includeLogs: true,
689
726
  }));
690
727
  if (deployment.logs) {
691
- deployment.logs = truncateLogs(deployment.logs, lines, max_chars ?? 50000);
728
+ const result = truncateLogs(deployment.logs, ll, max_chars ?? 50000, p);
729
+ deployment.logs = result.logs;
730
+ return {
731
+ ...deployment,
732
+ logs_meta: {
733
+ total_entries: result.total,
734
+ showing: `${result.showing_start}-${result.showing_end} of ${result.total}`,
735
+ },
736
+ };
692
737
  }
693
- return deployment;
694
- }, (dep) => getDeploymentActions(dep.uuid, dep.status, dep.application_uuid));
738
+ return { ...deployment, logs_meta: undefined };
739
+ }, (dep) => getDeploymentActions(dep.uuid, dep.status, dep.application_uuid), (dep) => {
740
+ const total = dep.logs_meta?.total_entries ?? 0;
741
+ const hasOlder = p * ll < total;
742
+ const pagination = {};
743
+ if (hasOlder)
744
+ pagination.next = {
745
+ tool: 'deployment',
746
+ args: { action: 'get', uuid, lines: ll, page: p + 1 },
747
+ };
748
+ if (p > 1)
749
+ pagination.prev = {
750
+ tool: 'deployment',
751
+ args: { action: 'get', uuid, lines: ll, page: p - 1 },
752
+ };
753
+ return Object.keys(pagination).length > 0 ? pagination : undefined;
754
+ });
695
755
  }
696
756
  // Otherwise return essential info without logs
697
757
  return wrapWithActions(() => this.client.getDeployment(uuid), (dep) => getDeploymentActions(dep.uuid, dep.status, dep.application_uuid));
@@ -907,11 +907,11 @@ export interface ResponseAction {
907
907
  export interface ResponsePagination {
908
908
  next?: {
909
909
  tool: string;
910
- args: Record<string, number>;
910
+ args: Record<string, string | number>;
911
911
  };
912
912
  prev?: {
913
913
  tool: string;
914
- args: Record<string, number>;
914
+ args: Record<string, string | number>;
915
915
  };
916
916
  }
917
917
  export interface DeploymentEssential {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@masonator/coolify-mcp",
3
3
  "scope": "@masonator",
4
- "version": "2.6.5",
4
+ "version": "2.6.6",
5
5
  "description": "MCP server implementation for Coolify",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",