@masonator/coolify-mcp 2.6.5 → 2.7.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
@@ -15,7 +15,7 @@ A Model Context Protocol (MCP) server for [Coolify](https://coolify.io/), enabli
15
15
 
16
16
  ## Features
17
17
 
18
- This MCP server provides **35 token-optimized tools** for **debugging, management, and deployment**:
18
+ This MCP server provides **38 token-optimized tools** for **debugging, management, and deployment**:
19
19
 
20
20
  | Category | Tools |
21
21
  | -------------------- | --------------------------------------------------------------------------------------------------------------------------- |
@@ -33,6 +33,9 @@ This MCP server provides **35 token-optimized tools** for **debugging, managemen
33
33
  | **Deployments** | `list_deployments`, `deploy`, `deployment` (get, cancel, list_for_app) |
34
34
  | **Private Keys** | `private_keys` (list, get, create, update, delete via action param) |
35
35
  | **GitHub Apps** | `github_apps` (list, get, create, update, delete via action param) |
36
+ | **Teams** | `teams` (list, get, get_members, get_current, get_current_members) |
37
+ | **Cloud Tokens** | `cloud_tokens` (Hetzner/DigitalOcean: list, get, create, update, delete, validate) |
38
+ | **Documentation** | `search_docs` (full-text search across Coolify docs) |
36
39
 
37
40
  ### Token-Optimized Design
38
41
 
@@ -1577,6 +1577,47 @@ describe('CoolifyClient', () => {
1577
1577
  const result = await client.getVersion();
1578
1578
  expect(result).toEqual({ version: 'v4.0.0-beta.123' });
1579
1579
  });
1580
+ it('should cache version after first call', async () => {
1581
+ mockFetch.mockResolvedValueOnce({
1582
+ ok: true,
1583
+ status: 200,
1584
+ text: async () => 'v4.0.0-beta.462',
1585
+ });
1586
+ const first = await client.getVersion();
1587
+ const second = await client.getVersion();
1588
+ expect(first).toEqual({ version: 'v4.0.0-beta.462' });
1589
+ expect(second).toEqual({ version: 'v4.0.0-beta.462' });
1590
+ // Only one fetch call — second was served from cache
1591
+ expect(mockFetch).toHaveBeenCalledTimes(1);
1592
+ });
1593
+ it('should return null from getCachedVersion before first call', () => {
1594
+ expect(client.getCachedVersion()).toBeNull();
1595
+ });
1596
+ it('should return version from getCachedVersion after first call', async () => {
1597
+ mockFetch.mockResolvedValueOnce({
1598
+ ok: true,
1599
+ status: 200,
1600
+ text: async () => 'v4.0.0-beta.462',
1601
+ });
1602
+ await client.getVersion();
1603
+ expect(client.getCachedVersion()).toBe('v4.0.0-beta.462');
1604
+ });
1605
+ it('should not cache version on error and retry on next call', async () => {
1606
+ mockFetch.mockResolvedValueOnce({
1607
+ ok: false,
1608
+ status: 500,
1609
+ statusText: 'Internal Server Error',
1610
+ });
1611
+ await expect(client.getVersion()).rejects.toThrow();
1612
+ expect(client.getCachedVersion()).toBeNull();
1613
+ mockFetch.mockResolvedValueOnce({
1614
+ ok: true,
1615
+ status: 200,
1616
+ text: async () => 'v4.0.0-beta.462',
1617
+ });
1618
+ const result = await client.getVersion();
1619
+ expect(result).toEqual({ version: 'v4.0.0-beta.462' });
1620
+ });
1580
1621
  it('should handle version errors', async () => {
1581
1622
  mockFetch.mockResolvedValueOnce({
1582
1623
  ok: false,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,220 @@
1
+ import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
2
+ import { DocsSearchEngine, parseDocs } from '../lib/docs-search.js';
3
+ // Sample llms-full.txt content for testing
4
+ const SAMPLE_DOCS = `---
5
+ url: /docs/get-started/installation.md
6
+ description: >-
7
+ Install Coolify self-hosted PaaS on Linux servers with automated Docker setup
8
+ script and SSH access.
9
+ ---
10
+
11
+ # Installation
12
+
13
+ Coolify can be installed on any Linux server.
14
+
15
+ ## Requirements
16
+
17
+ You need a server with at least 2GB RAM and 2 CPU cores.
18
+ SSH access is required for the installation process.
19
+
20
+ ## Quick Install
21
+
22
+ Run the following command to install Coolify:
23
+
24
+ \`\`\`bash
25
+ curl -fsSL https://cdn.coolify.io/install.sh | bash
26
+ \`\`\`
27
+
28
+ ---
29
+
30
+ ---
31
+ url: /docs/applications/docker-compose.md
32
+ description: >-
33
+ Deploy Docker Compose applications on Coolify with environment variables,
34
+ build packs, and custom domains.
35
+ ---
36
+
37
+ # Docker Compose
38
+
39
+ You can deploy any Docker Compose based application with Coolify.
40
+
41
+ ## Environment Variables
42
+
43
+ Define environment variables in your docker-compose.yml or through the Coolify UI.
44
+ Variables defined in the UI take precedence over those in the compose file.
45
+
46
+ ## Custom Domains
47
+
48
+ Set custom domains for your Docker Compose services through the Coolify dashboard.
49
+ Each service can have its own domain configuration.
50
+
51
+ ---
52
+
53
+ ---
54
+ url: /docs/troubleshoot/applications/502-error.md
55
+ description: >-
56
+ Fix 502 Bad Gateway errors in Coolify applications caused by health check
57
+ failures, port mismatches, and proxy configuration issues.
58
+ ---
59
+
60
+ # 502 Bad Gateway Error
61
+
62
+ A 502 error usually means your application is not responding to the reverse proxy.
63
+
64
+ ## Common Causes
65
+
66
+ Check the following:
67
+ - Your application is listening on the correct port
68
+ - Health checks are configured properly
69
+ - The container is actually running
70
+
71
+ ## Port Configuration
72
+
73
+ Make sure your application listens on the port specified in the Coolify settings.
74
+ The default exposed port is 3000 for most build packs.`;
75
+ describe('parseDocs', () => {
76
+ it('should parse pages from llms-full.txt format', () => {
77
+ const chunks = parseDocs(SAMPLE_DOCS);
78
+ expect(chunks.length).toBeGreaterThan(0);
79
+ });
80
+ it('should extract title, url, and description from frontmatter', () => {
81
+ const chunks = parseDocs(SAMPLE_DOCS);
82
+ const installChunk = chunks.find((c) => c.title === 'Installation');
83
+ expect(installChunk).toBeDefined();
84
+ expect(installChunk.url).toBe('https://coolify.io/docs/get-started/installation');
85
+ expect(installChunk.description).toContain('Install Coolify');
86
+ });
87
+ it('should split pages into sub-chunks at ## headers', () => {
88
+ const chunks = parseDocs(SAMPLE_DOCS);
89
+ const subChunks = chunks.filter((c) => c.title.includes('>'));
90
+ expect(subChunks.length).toBeGreaterThan(0);
91
+ expect(subChunks.some((c) => c.title.includes('Requirements'))).toBe(true);
92
+ });
93
+ it('should strip .md extension from URLs', () => {
94
+ const chunks = parseDocs(SAMPLE_DOCS);
95
+ chunks.forEach((c) => {
96
+ expect(c.url).not.toContain('.md');
97
+ });
98
+ });
99
+ it('should handle empty input', () => {
100
+ const chunks = parseDocs('');
101
+ expect(chunks).toEqual([]);
102
+ });
103
+ it('should assign sequential IDs', () => {
104
+ const chunks = parseDocs(SAMPLE_DOCS);
105
+ chunks.forEach((chunk, index) => {
106
+ expect(chunk.id).toBe(index);
107
+ });
108
+ });
109
+ });
110
+ describe('DocsSearchEngine', () => {
111
+ let engine;
112
+ let mockFetch;
113
+ beforeEach(() => {
114
+ engine = new DocsSearchEngine();
115
+ mockFetch = jest.spyOn(global, 'fetch');
116
+ });
117
+ afterEach(() => {
118
+ mockFetch.mockRestore();
119
+ });
120
+ it('should fetch and index docs on first search', async () => {
121
+ mockFetch.mockResolvedValueOnce({
122
+ ok: true,
123
+ text: async () => SAMPLE_DOCS,
124
+ });
125
+ const results = await engine.search('installation');
126
+ expect(results.length).toBeGreaterThan(0);
127
+ expect(mockFetch).toHaveBeenCalledTimes(1);
128
+ });
129
+ it('should deduplicate concurrent loading', async () => {
130
+ mockFetch.mockResolvedValueOnce({
131
+ ok: true,
132
+ text: async () => SAMPLE_DOCS,
133
+ });
134
+ const [results1, results2] = await Promise.all([
135
+ engine.search('installation'),
136
+ engine.search('docker'),
137
+ ]);
138
+ expect(mockFetch).toHaveBeenCalledTimes(1);
139
+ expect(results1.length).toBeGreaterThan(0);
140
+ expect(results2.length).toBeGreaterThan(0);
141
+ });
142
+ it('should only fetch once across multiple searches', async () => {
143
+ mockFetch.mockResolvedValueOnce({
144
+ ok: true,
145
+ text: async () => SAMPLE_DOCS,
146
+ });
147
+ await engine.search('installation');
148
+ await engine.search('docker compose');
149
+ expect(mockFetch).toHaveBeenCalledTimes(1);
150
+ });
151
+ it('should return results with title, url, description, snippet, score', async () => {
152
+ mockFetch.mockResolvedValueOnce({
153
+ ok: true,
154
+ text: async () => SAMPLE_DOCS,
155
+ });
156
+ const results = await engine.search('502 error');
157
+ expect(results.length).toBeGreaterThan(0);
158
+ const r = results[0];
159
+ expect(r).toHaveProperty('title');
160
+ expect(r).toHaveProperty('url');
161
+ expect(r).toHaveProperty('description');
162
+ expect(r).toHaveProperty('snippet');
163
+ expect(r).toHaveProperty('score');
164
+ expect(typeof r.score).toBe('number');
165
+ });
166
+ it('should rank relevant results higher', async () => {
167
+ mockFetch.mockResolvedValueOnce({
168
+ ok: true,
169
+ text: async () => SAMPLE_DOCS,
170
+ });
171
+ const results = await engine.search('docker compose environment variables');
172
+ expect(results[0].url).toContain('docker-compose');
173
+ });
174
+ it('should return empty array for no matches', async () => {
175
+ mockFetch.mockResolvedValueOnce({
176
+ ok: true,
177
+ text: async () => SAMPLE_DOCS,
178
+ });
179
+ const results = await engine.search('xyznonexistent12345');
180
+ expect(results).toEqual([]);
181
+ });
182
+ it('should respect limit parameter', async () => {
183
+ mockFetch.mockResolvedValueOnce({
184
+ ok: true,
185
+ text: async () => SAMPLE_DOCS,
186
+ });
187
+ const results = await engine.search('coolify', 2);
188
+ expect(results.length).toBeLessThanOrEqual(2);
189
+ });
190
+ it('should throw on fetch failure', async () => {
191
+ mockFetch.mockResolvedValueOnce({
192
+ ok: false,
193
+ status: 500,
194
+ });
195
+ await expect(engine.search('test')).rejects.toThrow('Failed to fetch Coolify docs');
196
+ });
197
+ it('should retry after fetch failure', async () => {
198
+ mockFetch.mockResolvedValueOnce({
199
+ ok: false,
200
+ status: 500,
201
+ });
202
+ await expect(engine.search('test')).rejects.toThrow();
203
+ // Second attempt should try fetching again (loading was reset)
204
+ mockFetch.mockResolvedValueOnce({
205
+ ok: true,
206
+ text: async () => SAMPLE_DOCS,
207
+ });
208
+ const results = await engine.search('installation');
209
+ expect(results.length).toBeGreaterThan(0);
210
+ });
211
+ it('should report chunk count after loading', async () => {
212
+ mockFetch.mockResolvedValueOnce({
213
+ ok: true,
214
+ text: async () => SAMPLE_DOCS,
215
+ });
216
+ expect(engine.getChunkCount()).toBe(0);
217
+ await engine.search('test');
218
+ expect(engine.getChunkCount()).toBeGreaterThan(0);
219
+ });
220
+ });
@@ -136,6 +136,21 @@ describe('CoolifyMcpServer v2', () => {
136
136
  expect(typeof client.bulkEnvUpdate).toBe('function');
137
137
  expect(typeof client.stopAllApps).toBe('function');
138
138
  expect(typeof client.redeployProjectApps).toBe('function');
139
+ // Team operations
140
+ expect(typeof client.listTeams).toBe('function');
141
+ expect(typeof client.getTeam).toBe('function');
142
+ expect(typeof client.getTeamMembers).toBe('function');
143
+ expect(typeof client.getCurrentTeam).toBe('function');
144
+ expect(typeof client.getCurrentTeamMembers).toBe('function');
145
+ // Cloud token operations
146
+ expect(typeof client.listCloudTokens).toBe('function');
147
+ expect(typeof client.getCloudToken).toBe('function');
148
+ expect(typeof client.createCloudToken).toBe('function');
149
+ expect(typeof client.updateCloudToken).toBe('function');
150
+ expect(typeof client.deleteCloudToken).toBe('function');
151
+ expect(typeof client.validateCloudToken).toBe('function');
152
+ // Version caching
153
+ expect(typeof client.getCachedVersion).toBe('function');
139
154
  });
140
155
  });
141
156
  describe('server configuration', () => {
@@ -148,41 +163,46 @@ describe('CoolifyMcpServer v2', () => {
148
163
  });
149
164
  });
150
165
  describe('truncateLogs', () => {
166
+ // Plain text log tests
151
167
  it('should return logs unchanged when within limits', () => {
152
168
  const logs = 'line1\nline2\nline3';
153
169
  const result = truncateLogs(logs, 200, 50000);
154
- expect(result).toBe(logs);
170
+ expect(result.logs).toBe(logs);
171
+ expect(result.total).toBe(3);
155
172
  });
156
173
  it('should truncate to last N lines', () => {
157
174
  const logs = 'line1\nline2\nline3\nline4\nline5';
158
175
  const result = truncateLogs(logs, 3, 50000);
159
- expect(result).toBe('line3\nline4\nline5');
176
+ expect(result.logs).toBe('line3\nline4\nline5');
177
+ expect(result.total).toBe(5);
178
+ expect(result.showing_start).toBe(3);
179
+ expect(result.showing_end).toBe(5);
160
180
  });
161
181
  it('should truncate by character limit when lines are huge', () => {
162
182
  const hugeLine = 'x'.repeat(100);
163
183
  const logs = `${hugeLine}\n${hugeLine}\n${hugeLine}`;
164
184
  const result = truncateLogs(logs, 200, 50);
165
- expect(result.length).toBeLessThanOrEqual(50);
166
- expect(result.startsWith('...[truncated]...')).toBe(true);
185
+ expect(result.logs.length).toBeLessThanOrEqual(50);
186
+ expect(result.logs.startsWith('...[truncated]...')).toBe(true);
167
187
  });
168
188
  it('should not add truncation prefix when under char limit', () => {
169
189
  const logs = 'line1\nline2\nline3';
170
190
  const result = truncateLogs(logs, 200, 50000);
171
- expect(result.startsWith('...[truncated]...')).toBe(false);
191
+ expect(result.logs.startsWith('...[truncated]...')).toBe(false);
172
192
  });
173
193
  it('should handle empty logs', () => {
174
194
  const result = truncateLogs('', 200, 50000);
175
- expect(result).toBe('');
195
+ expect(result.logs).toBe('');
176
196
  });
177
197
  it('should use default limits when not specified', () => {
178
198
  const logs = 'line1\nline2';
179
199
  const result = truncateLogs(logs);
180
- expect(result).toBe(logs);
200
+ expect(result.logs).toBe(logs);
181
201
  });
182
202
  it('should respect custom line limit', () => {
183
203
  const lines = Array.from({ length: 300 }, (_, i) => `line${i + 1}`).join('\n');
184
204
  const result = truncateLogs(lines, 50, 50000);
185
- const resultLines = result.split('\n');
205
+ const resultLines = result.logs.split('\n');
186
206
  expect(resultLines.length).toBe(50);
187
207
  expect(resultLines[0]).toBe('line251');
188
208
  expect(resultLines[49]).toBe('line300');
@@ -190,7 +210,90 @@ describe('truncateLogs', () => {
190
210
  it('should respect custom char limit', () => {
191
211
  const logs = 'x'.repeat(1000);
192
212
  const result = truncateLogs(logs, 200, 100);
193
- expect(result.length).toBe(100);
213
+ expect(result.logs.length).toBe(100);
214
+ });
215
+ // Pagination tests (plain text)
216
+ it('should paginate plain text logs (page 2 = older entries)', () => {
217
+ const logs = Array.from({ length: 30 }, (_, i) => `line${i + 1}`).join('\n');
218
+ const page1 = truncateLogs(logs, 10, 50000, 1);
219
+ const page2 = truncateLogs(logs, 10, 50000, 2);
220
+ const page3 = truncateLogs(logs, 10, 50000, 3);
221
+ expect(page1.logs).toContain('line30');
222
+ expect(page1.logs).toContain('line21');
223
+ expect(page1.logs).not.toContain('line20');
224
+ expect(page2.logs).toContain('line20');
225
+ expect(page2.logs).toContain('line11');
226
+ expect(page2.logs).not.toContain('line10');
227
+ expect(page3.logs).toContain('line10');
228
+ expect(page3.logs).toContain('line1');
229
+ expect(page1.showing_start).toBe(21);
230
+ expect(page1.showing_end).toBe(30);
231
+ });
232
+ // JSON array format tests (Coolify deployment logs)
233
+ it('should parse JSON array logs and return last N visible entries', () => {
234
+ const entries = [
235
+ { output: 'Building...', timestamp: '2026-01-01T00:00:01Z', hidden: false },
236
+ { output: 'docker pull', timestamp: '2026-01-01T00:00:02Z', hidden: true },
237
+ { output: 'Compiling...', timestamp: '2026-01-01T00:00:03Z', hidden: false },
238
+ { output: 'Done.', timestamp: '2026-01-01T00:00:04Z', hidden: false },
239
+ ];
240
+ const result = truncateLogs(JSON.stringify(entries), 2, 50000);
241
+ expect(result.logs).toContain('Compiling...');
242
+ expect(result.logs).toContain('Done.');
243
+ expect(result.logs).not.toContain('Building...');
244
+ expect(result.logs).not.toContain('docker pull');
245
+ expect(result.total).toBe(3); // 3 visible entries
246
+ });
247
+ it('should filter hidden entries from JSON logs', () => {
248
+ const entries = [
249
+ { output: 'visible1', timestamp: '2026-01-01T00:00:01Z', hidden: false },
250
+ { output: 'hidden1', timestamp: '2026-01-01T00:00:02Z', hidden: true },
251
+ { output: 'hidden2', timestamp: '2026-01-01T00:00:03Z', hidden: true },
252
+ { output: 'visible2', timestamp: '2026-01-01T00:00:04Z', hidden: false },
253
+ ];
254
+ const result = truncateLogs(JSON.stringify(entries), 200, 50000);
255
+ expect(result.logs).toContain('visible1');
256
+ expect(result.logs).toContain('visible2');
257
+ expect(result.logs).not.toContain('hidden1');
258
+ expect(result.logs).not.toContain('hidden2');
259
+ });
260
+ it('should format JSON log entries with timestamp and output', () => {
261
+ const entries = [
262
+ { output: 'Starting deploy', timestamp: '2026-01-01T10:00:00Z', hidden: false },
263
+ ];
264
+ const result = truncateLogs(JSON.stringify(entries), 200, 50000);
265
+ expect(result.logs).toBe('[2026-01-01T10:00:00Z] Starting deploy');
266
+ });
267
+ it('should paginate JSON logs (page 2 = older entries)', () => {
268
+ const entries = Array.from({ length: 30 }, (_, i) => ({
269
+ output: `step ${i + 1}`,
270
+ timestamp: `2026-01-01T00:00:${String(i).padStart(2, '0')}Z`,
271
+ hidden: false,
272
+ }));
273
+ const page1 = truncateLogs(JSON.stringify(entries), 10, 50000, 1);
274
+ const page2 = truncateLogs(JSON.stringify(entries), 10, 50000, 2);
275
+ expect(page1.logs).toContain('step 30');
276
+ expect(page1.logs).toContain('step 21');
277
+ expect(page1.logs).not.toContain('step 20');
278
+ expect(page2.logs).toContain('step 20');
279
+ expect(page2.logs).toContain('step 11');
280
+ expect(page2.logs).not.toContain('step 10');
281
+ expect(page1.total).toBe(30);
282
+ expect(page1.showing_start).toBe(21);
283
+ expect(page1.showing_end).toBe(30);
284
+ expect(page2.showing_start).toBe(11);
285
+ expect(page2.showing_end).toBe(20);
286
+ });
287
+ it('should return metadata with total and showing range', () => {
288
+ const entries = Array.from({ length: 50 }, (_, i) => ({
289
+ output: `step ${i}`,
290
+ timestamp: `2026-01-01T00:00:${String(i).padStart(2, '0')}Z`,
291
+ hidden: false,
292
+ }));
293
+ const result = truncateLogs(JSON.stringify(entries), 10, 50000);
294
+ expect(result.total).toBe(50);
295
+ expect(result.showing_start).toBe(41);
296
+ expect(result.showing_end).toBe(50);
194
297
  });
195
298
  });
196
299
  // =============================================================================
@@ -72,10 +72,12 @@ export interface GitHubAppSummary {
72
72
  export declare class CoolifyClient {
73
73
  private readonly baseUrl;
74
74
  private readonly accessToken;
75
+ private cachedVersion;
75
76
  constructor(config: CoolifyConfig);
76
77
  private request;
77
78
  private buildQueryString;
78
79
  getVersion(): Promise<Version>;
80
+ getCachedVersion(): string | null;
79
81
  validateConnection(): Promise<void>;
80
82
  listServers(options?: ListOptions): Promise<Server[] | ServerSummary[]>;
81
83
  getServer(uuid: string): Promise<Server>;
@@ -144,6 +144,7 @@ function toEnvVarSummary(envVar) {
144
144
  */
145
145
  export class CoolifyClient {
146
146
  constructor(config) {
147
+ this.cachedVersion = null;
147
148
  if (!config.baseUrl) {
148
149
  throw new Error('Coolify base URL is required');
149
150
  }
@@ -205,6 +206,9 @@ export class CoolifyClient {
205
206
  // Health & Version
206
207
  // ===========================================================================
207
208
  async getVersion() {
209
+ if (this.cachedVersion) {
210
+ return { version: this.cachedVersion };
211
+ }
208
212
  // The /version endpoint returns plain text, not JSON
209
213
  const url = `${this.baseUrl}/api/v1/version`;
210
214
  const response = await fetch(url, {
@@ -216,7 +220,11 @@ export class CoolifyClient {
216
220
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
217
221
  }
218
222
  const version = await response.text();
219
- return { version: version.trim() };
223
+ this.cachedVersion = version.trim();
224
+ return { version: this.cachedVersion };
225
+ }
226
+ getCachedVersion() {
227
+ return this.cachedVersion;
220
228
  }
221
229
  async validateConnection() {
222
230
  try {
@@ -0,0 +1,32 @@
1
+ interface DocChunk {
2
+ id: number;
3
+ title: string;
4
+ url: string;
5
+ description: string;
6
+ content: string;
7
+ }
8
+ export interface DocSearchResult {
9
+ title: string;
10
+ url: string;
11
+ description: string;
12
+ snippet: string;
13
+ score: number;
14
+ }
15
+ /**
16
+ * Lightweight full-text search over Coolify documentation.
17
+ * Fetches llms-full.txt on first search, parses into chunks, indexes with MiniSearch (BM25).
18
+ * The LLM calling this tool handles semantic understanding — we just need good ranking.
19
+ */
20
+ export declare class DocsSearchEngine {
21
+ private index;
22
+ private chunks;
23
+ private loading;
24
+ ensureLoaded(): Promise<void>;
25
+ private loadAndIndex;
26
+ search(query: string, limit?: number): Promise<DocSearchResult[]>;
27
+ private getSnippet;
28
+ getChunkCount(): number;
29
+ }
30
+ /** Parse llms-full.txt into doc chunks. Exported for testing. */
31
+ export declare function parseDocs(text: string): DocChunk[];
32
+ export {};
@@ -0,0 +1,152 @@
1
+ import MiniSearch from 'minisearch';
2
+ const DOCS_FULL_URL = 'https://coolify.io/docs/llms-full.txt';
3
+ const DOCS_BASE_URL = 'https://coolify.io';
4
+ /**
5
+ * Lightweight full-text search over Coolify documentation.
6
+ * Fetches llms-full.txt on first search, parses into chunks, indexes with MiniSearch (BM25).
7
+ * The LLM calling this tool handles semantic understanding — we just need good ranking.
8
+ */
9
+ export class DocsSearchEngine {
10
+ constructor() {
11
+ this.index = null;
12
+ this.chunks = [];
13
+ this.loading = null;
14
+ }
15
+ async ensureLoaded() {
16
+ if (this.index)
17
+ return;
18
+ if (this.loading)
19
+ return this.loading;
20
+ this.loading = this.loadAndIndex();
21
+ return this.loading;
22
+ }
23
+ async loadAndIndex() {
24
+ try {
25
+ const controller = new AbortController();
26
+ const timeout = setTimeout(() => controller.abort(), 15000);
27
+ let response;
28
+ try {
29
+ response = await fetch(DOCS_FULL_URL, { signal: controller.signal });
30
+ }
31
+ finally {
32
+ clearTimeout(timeout);
33
+ }
34
+ if (!response.ok) {
35
+ throw new Error(`Failed to fetch Coolify docs: HTTP ${response.status}`);
36
+ }
37
+ const text = await response.text();
38
+ this.chunks = parseDocs(text);
39
+ this.index = new MiniSearch({
40
+ fields: ['title', 'description', 'content'],
41
+ storeFields: ['title', 'url', 'description'],
42
+ searchOptions: {
43
+ boost: { title: 3, description: 2, content: 1 },
44
+ prefix: true,
45
+ fuzzy: 0.2,
46
+ },
47
+ });
48
+ this.index.addAll(this.chunks);
49
+ }
50
+ catch (error) {
51
+ this.loading = null;
52
+ this.index = null;
53
+ this.chunks = [];
54
+ throw error;
55
+ }
56
+ }
57
+ async search(query, limit = 5) {
58
+ await this.ensureLoaded();
59
+ if (!this.index) {
60
+ throw new Error('Documentation index failed to load');
61
+ }
62
+ const results = this.index.search(query).slice(0, limit);
63
+ return results.map((r) => ({
64
+ title: r.title,
65
+ url: r.url,
66
+ description: r.description,
67
+ snippet: this.getSnippet(r.id, query),
68
+ score: Math.round(r.score * 100) / 100,
69
+ }));
70
+ }
71
+ getSnippet(id, query) {
72
+ const chunk = this.chunks[id];
73
+ if (!chunk)
74
+ return '';
75
+ const content = chunk.content;
76
+ const queryTerms = query.toLowerCase().split(/\s+/);
77
+ // Find best position — where query terms appear
78
+ let bestPos = 0;
79
+ let bestScore = -1;
80
+ const lower = content.toLowerCase();
81
+ for (let i = 0; i < lower.length - 100; i += 50) {
82
+ const window = lower.slice(i, i + 300);
83
+ const score = queryTerms.reduce((s, t) => s + (window.includes(t) ? 1 : 0), 0);
84
+ if (score > bestScore) {
85
+ bestScore = score;
86
+ bestPos = i;
87
+ }
88
+ }
89
+ const start = Math.max(0, bestPos);
90
+ const end = Math.min(content.length, start + 300);
91
+ let snippet = content.slice(start, end).trim();
92
+ if (start > 0)
93
+ snippet = '...' + snippet;
94
+ if (end < content.length)
95
+ snippet = snippet + '...';
96
+ return snippet;
97
+ }
98
+ getChunkCount() {
99
+ return this.chunks.length;
100
+ }
101
+ }
102
+ /** Parse llms-full.txt into doc chunks. Exported for testing. */
103
+ export function parseDocs(text) {
104
+ const chunks = [];
105
+ // Split on page boundaries: ---\n\n--- or end of frontmatter pairs
106
+ // Each page starts with ---\nurl: ...\ndescription: ...\n---\n then markdown
107
+ const pages = text.split(/\n---\n\n---\n/);
108
+ for (const page of pages) {
109
+ const parsed = parsePage(page);
110
+ if (!parsed)
111
+ continue;
112
+ // Split large pages into sub-chunks at ## headers
113
+ const sections = parsed.content.split(/\n(?=## )/);
114
+ for (const section of sections) {
115
+ const trimmed = section.trim();
116
+ if (!trimmed || trimmed.length < 20)
117
+ continue;
118
+ // Extract section title if present
119
+ const sectionTitle = trimmed.match(/^## (.+)/)?.[1];
120
+ const title = sectionTitle ? `${parsed.title} > ${sectionTitle}` : parsed.title;
121
+ chunks.push({
122
+ id: chunks.length,
123
+ title,
124
+ url: parsed.url,
125
+ description: parsed.description,
126
+ content: trimmed.replace(/^## .+\n/, '').trim(),
127
+ });
128
+ }
129
+ }
130
+ return chunks;
131
+ }
132
+ function parsePage(raw) {
133
+ // Handle frontmatter — may start with --- or just url:
134
+ const frontmatterMatch = raw.match(/(?:---\n)?url:\s*(.+)\ndescription:\s*>?-?\n?([\s\S]*?)\n---\n([\s\S]*)/);
135
+ if (!frontmatterMatch)
136
+ return null;
137
+ const urlPath = frontmatterMatch[1].trim();
138
+ const description = frontmatterMatch[2]
139
+ .split('\n')
140
+ .map((l) => l.trim())
141
+ .join(' ')
142
+ .trim();
143
+ const content = frontmatterMatch[3].trim();
144
+ // Extract H1 title from content
145
+ const titleMatch = content.match(/^#\s+(.+)/m);
146
+ const title = titleMatch?.[1] || urlPath;
147
+ // Build full URL
148
+ const url = urlPath.endsWith('.md')
149
+ ? DOCS_BASE_URL + urlPath.replace(/\.md$/, '')
150
+ : DOCS_BASE_URL + urlPath;
151
+ return { url, description, title, content };
152
+ }
@@ -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 */
@@ -19,6 +27,7 @@ export declare function getDeploymentActions(uuid: string, status: string, appUu
19
27
  export declare function getPagination(tool: string, page?: number, perPage?: number, count?: number): ResponsePagination | undefined;
20
28
  export declare class CoolifyMcpServer extends McpServer {
21
29
  private readonly client;
30
+ private readonly docsSearch;
22
31
  constructor(config: CoolifyConfig);
23
32
  connect(transport: Transport): Promise<void>;
24
33
  private registerTools;
@@ -6,6 +6,7 @@ import { createRequire } from 'module';
6
6
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
7
  import { z } from 'zod';
8
8
  import { CoolifyClient, } from './coolify-client.js';
9
+ import { DocsSearchEngine } from './docs-search.js';
9
10
  const _require = createRequire(import.meta.url);
10
11
  export const VERSION = _require('../../package.json').version;
11
12
  /** Wrap handler with error handling */
@@ -25,21 +26,55 @@ function wrap(fn) {
25
26
  }
26
27
  const TRUNCATION_PREFIX = '...[truncated]...\n';
27
28
  /**
28
- * Truncate logs by line count and character count.
29
+ * Truncate logs by entry count with pagination support.
30
+ * Handles both JSON array format (Coolify deployment logs) and plain text.
31
+ * Page 1 = most recent entries, page 2 = next older batch, etc.
29
32
  * Exported for testing.
30
33
  */
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
34
+ export function truncateLogs(logs, lineLimit = 200, charLimit = 50000, page = 1) {
35
+ // Try parsing as JSON array (Coolify deployment log format)
36
+ let lines;
37
+ let total;
38
+ try {
39
+ const entries = JSON.parse(logs);
40
+ if (Array.isArray(entries)) {
41
+ const visible = entries.filter((e) => !e.hidden);
42
+ total = visible.length;
43
+ const end = total - (page - 1) * lineLimit;
44
+ const start = Math.max(0, end - lineLimit);
45
+ const slice = visible.slice(start, end);
46
+ lines = slice.map((e) => `[${e.timestamp ?? ''}] ${e.output ?? ''}`);
47
+ }
48
+ else {
49
+ const allLines = logs.split('\n');
50
+ total = allLines.length;
51
+ const end = total - (page - 1) * lineLimit;
52
+ const start = Math.max(0, end - lineLimit);
53
+ lines = allLines.slice(start, end);
54
+ }
55
+ }
56
+ catch {
57
+ // Plain text logs — split by newlines
58
+ const allLines = logs.split('\n');
59
+ total = allLines.length;
60
+ const end = total - (page - 1) * lineLimit;
61
+ const start = Math.max(0, end - lineLimit);
62
+ lines = allLines.slice(start, end);
63
+ }
64
+ const end = total - (page - 1) * lineLimit;
65
+ const start = Math.max(0, end - lineLimit);
66
+ let result = lines.join('\n');
67
+ // Safety net: limit by characters
68
+ if (result.length > charLimit) {
39
69
  const prefixLen = TRUNCATION_PREFIX.length;
40
- truncatedLogs = TRUNCATION_PREFIX + truncatedLogs.slice(-(charLimit - prefixLen));
70
+ result = TRUNCATION_PREFIX + result.slice(-(charLimit - prefixLen));
41
71
  }
42
- return truncatedLogs;
72
+ return {
73
+ logs: result,
74
+ total,
75
+ showing_start: start + 1,
76
+ showing_end: Math.min(end, total),
77
+ };
43
78
  }
44
79
  // =============================================================================
45
80
  // Action Generators for HATEOAS-style responses
@@ -120,6 +155,7 @@ function wrapWithActions(fn, getActions, getPaginationFn) {
120
155
  export class CoolifyMcpServer extends McpServer {
121
156
  constructor(config) {
122
157
  super({ name: 'coolify', version: VERSION });
158
+ this.docsSearch = new DocsSearchEngine();
123
159
  this.client = new CoolifyClient(config);
124
160
  this.registerTools();
125
161
  }
@@ -676,22 +712,48 @@ export class CoolifyMcpServer extends McpServer {
676
712
  this.tool('deployment', 'Manage deployment: get/cancel/list_for_app (logs excluded by default, use lines param to include)', {
677
713
  action: z.enum(['get', 'cancel', 'list_for_app']),
678
714
  uuid: z.string(),
679
- lines: z.number().optional(), // Include logs truncated to last N lines (omit for no logs)
715
+ lines: z.number().optional(), // Include logs truncated to last N entries (omit for no logs)
716
+ page: z.number().optional(), // Log page (1=most recent, 2=older, etc.)
680
717
  max_chars: z.number().optional(), // Limit log output to last N chars (default: 50000)
681
- }, async ({ action, uuid, lines, max_chars }) => {
718
+ }, async ({ action, uuid, lines, page, max_chars }) => {
682
719
  switch (action) {
683
720
  case 'get':
684
721
  // If lines param specified, include logs and truncate
685
722
  if (lines !== undefined) {
723
+ const p = page ?? 1;
724
+ const ll = lines;
686
725
  return wrapWithActions(async () => {
687
726
  const deployment = (await this.client.getDeployment(uuid, {
688
727
  includeLogs: true,
689
728
  }));
690
729
  if (deployment.logs) {
691
- deployment.logs = truncateLogs(deployment.logs, lines, max_chars ?? 50000);
730
+ const result = truncateLogs(deployment.logs, ll, max_chars ?? 50000, p);
731
+ deployment.logs = result.logs;
732
+ return {
733
+ ...deployment,
734
+ logs_meta: {
735
+ total_entries: result.total,
736
+ showing: `${result.showing_start}-${result.showing_end} of ${result.total}`,
737
+ },
738
+ };
692
739
  }
693
- return deployment;
694
- }, (dep) => getDeploymentActions(dep.uuid, dep.status, dep.application_uuid));
740
+ return { ...deployment, logs_meta: undefined };
741
+ }, (dep) => getDeploymentActions(dep.uuid, dep.status, dep.application_uuid), (dep) => {
742
+ const total = dep.logs_meta?.total_entries ?? 0;
743
+ const hasOlder = p * ll < total;
744
+ const pagination = {};
745
+ if (hasOlder)
746
+ pagination.next = {
747
+ tool: 'deployment',
748
+ args: { action: 'get', uuid, lines: ll, page: p + 1 },
749
+ };
750
+ if (p > 1)
751
+ pagination.prev = {
752
+ tool: 'deployment',
753
+ args: { action: 'get', uuid, lines: ll, page: p - 1 },
754
+ };
755
+ return Object.keys(pagination).length > 0 ? pagination : undefined;
756
+ });
695
757
  }
696
758
  // Otherwise return essential info without logs
697
759
  return wrapWithActions(() => this.client.getDeployment(uuid), (dep) => getDeploymentActions(dep.uuid, dep.status, dep.application_uuid));
@@ -886,6 +948,80 @@ export class CoolifyMcpServer extends McpServer {
886
948
  }
887
949
  });
888
950
  // =========================================================================
951
+ // Teams (1 tool - consolidated)
952
+ // =========================================================================
953
+ this.tool('teams', 'Manage teams: list/get/get_members/get_current/get_current_members', {
954
+ action: z.enum(['list', 'get', 'get_members', 'get_current', 'get_current_members']),
955
+ id: z.number().optional(),
956
+ }, async ({ action, id }) => {
957
+ switch (action) {
958
+ case 'list':
959
+ return wrap(() => this.client.listTeams());
960
+ case 'get':
961
+ if (!id)
962
+ return { content: [{ type: 'text', text: 'Error: id required' }] };
963
+ return wrap(() => this.client.getTeam(id));
964
+ case 'get_members':
965
+ if (!id)
966
+ return { content: [{ type: 'text', text: 'Error: id required' }] };
967
+ return wrap(() => this.client.getTeamMembers(id));
968
+ case 'get_current':
969
+ return wrap(() => this.client.getCurrentTeam());
970
+ case 'get_current_members':
971
+ return wrap(() => this.client.getCurrentTeamMembers());
972
+ }
973
+ });
974
+ // =========================================================================
975
+ // Cloud Tokens (1 tool - consolidated)
976
+ // =========================================================================
977
+ this.tool('cloud_tokens', 'Manage cloud provider tokens (Hetzner/DigitalOcean): list/get/create/update/delete/validate', {
978
+ action: z.enum(['list', 'get', 'create', 'update', 'delete', 'validate']),
979
+ uuid: z.string().optional(),
980
+ provider: z.enum(['hetzner', 'digitalocean']).optional(),
981
+ token: z.string().optional(),
982
+ name: z.string().optional(),
983
+ }, async ({ action, uuid, provider, token, name }) => {
984
+ switch (action) {
985
+ case 'list':
986
+ return wrap(() => this.client.listCloudTokens());
987
+ case 'get':
988
+ if (!uuid)
989
+ return { content: [{ type: 'text', text: 'Error: uuid required' }] };
990
+ return wrap(() => this.client.getCloudToken(uuid));
991
+ case 'create':
992
+ if (!provider || !token || !name)
993
+ return {
994
+ content: [{ type: 'text', text: 'Error: provider, token, name required' }],
995
+ };
996
+ return wrap(() => this.client.createCloudToken({ provider, token, name }));
997
+ case 'update':
998
+ if (!uuid)
999
+ return { content: [{ type: 'text', text: 'Error: uuid required' }] };
1000
+ return wrap(() => this.client.updateCloudToken(uuid, { name }));
1001
+ case 'delete':
1002
+ if (!uuid)
1003
+ return { content: [{ type: 'text', text: 'Error: uuid required' }] };
1004
+ return wrap(() => this.client.deleteCloudToken(uuid));
1005
+ case 'validate':
1006
+ if (!uuid)
1007
+ return { content: [{ type: 'text', text: 'Error: uuid required' }] };
1008
+ return wrap(() => this.client.validateCloudToken(uuid));
1009
+ }
1010
+ });
1011
+ // =========================================================================
1012
+ // Documentation Search (1 tool)
1013
+ // =========================================================================
1014
+ this.tool('search_docs', 'Search Coolify documentation for how-to guides, configuration, troubleshooting', {
1015
+ query: z.string().describe('Search query'),
1016
+ limit: z.number().optional().describe('Max results (default 5)'),
1017
+ }, async ({ query, limit }) => wrap(async () => {
1018
+ const results = await this.docsSearch.search(query, limit ?? 5);
1019
+ if (results.length === 0) {
1020
+ return { results: [], hint: 'No matches. Try broader or different keywords.' };
1021
+ }
1022
+ return { results };
1023
+ }));
1024
+ // =========================================================================
889
1025
  // Batch Operations (4 tools)
890
1026
  // =========================================================================
891
1027
  this.tool('restart_project_apps', 'Restart all apps in project', { project_uuid: z.string() }, async ({ project_uuid }) => wrap(() => this.client.restartProjectApps(project_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.7.0",
5
5
  "description": "MCP server implementation for Coolify",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -46,6 +46,7 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.23.0",
49
+ "minisearch": "^7.2.0",
49
50
  "zod": "^4.3.5"
50
51
  },
51
52
  "devDependencies": {