@masonator/coolify-mcp 2.6.6 → 2.7.1

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
@@ -9,13 +9,13 @@
9
9
  [![codecov](https://codecov.io/gh/StuMason/coolify-mcp/branch/main/graph/badge.svg)](https://codecov.io/gh/StuMason/coolify-mcp)
10
10
  [![MseeP.ai Security Assessment Badge](https://mseep.net/pr/stumason-coolify-mcp-badge.png)](https://mseep.ai/app/stumason-coolify-mcp)
11
11
 
12
- > **The most comprehensive MCP server for Coolify** - 35 optimized tools, smart diagnostics, and batch operations for managing your self-hosted PaaS through AI assistants.
12
+ > **The most comprehensive MCP server for Coolify** - 38 optimized tools, smart diagnostics, documentation search, and batch operations for managing your self-hosted PaaS through AI assistants.
13
13
 
14
14
  A Model Context Protocol (MCP) server for [Coolify](https://coolify.io/), enabling AI assistants to manage and debug your Coolify instances through natural language.
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
 
@@ -188,6 +191,24 @@ Deploy nginx:latest from Docker Hub
188
191
  Deploy from public repo https://github.com/org/repo
189
192
  ```
190
193
 
194
+ ### Documentation & Help
195
+
196
+ ```text
197
+ How do I set up Docker Compose with Coolify?
198
+ Search the docs for health check configuration
199
+ How do I fix a 502 Bad Gateway error?
200
+ What are Coolify environment variables?
201
+ ```
202
+
203
+ ### Teams & Cloud Providers
204
+
205
+ ```text
206
+ Who has access to my Coolify instance?
207
+ Show me the current team members
208
+ List my cloud provider tokens
209
+ Validate my Hetzner API token
210
+ ```
211
+
191
212
  ## Environment Variables
192
213
 
193
214
  | Variable | Required | Default | Description |
@@ -235,8 +256,8 @@ These tools accept human-friendly identifiers instead of just UUIDs:
235
256
 
236
257
  - `list_servers` - List all servers (returns summary)
237
258
  - `get_server` - Get server details
238
- - `get_server_resources` - Get resources running on a server
239
- - `get_server_domains` - Get domains configured on a server
259
+ - `server_resources` - Get resources running on a server
260
+ - `server_domains` - Get domains configured on a server
240
261
  - `validate_server` - Validate server connection
241
262
 
242
263
  ### Projects
@@ -281,12 +302,28 @@ These tools accept human-friendly identifiers instead of just UUIDs:
281
302
 
282
303
  - `list_deployments` - List running deployments (returns summary)
283
304
  - `deploy` - Deploy by tag or UUID
284
- - `deployment` - Manage deployments with `action: get|cancel|list_for_app` (supports `lines` param to limit log output)
305
+ - `deployment` - Manage deployments with `action: get|cancel|list_for_app` (supports `lines` and `page` params for paginated log output with `logs_meta`)
285
306
 
286
307
  ### Private Keys
287
308
 
288
309
  - `private_keys` - Manage SSH keys with `action: list|get|create|update|delete`
289
310
 
311
+ ### GitHub Apps
312
+
313
+ - `github_apps` - Manage GitHub App integrations with `action: list|get|create|update|delete`
314
+
315
+ ### Teams
316
+
317
+ - `teams` - Manage teams with `action: list|get|get_members|get_current|get_current_members`
318
+
319
+ ### Cloud Tokens
320
+
321
+ - `cloud_tokens` - Manage cloud provider tokens (Hetzner/DigitalOcean) with `action: list|get|create|update|delete|validate`
322
+
323
+ ### Documentation
324
+
325
+ - `search_docs` - Search Coolify documentation using full-text search. Indexes 1,500+ doc chunks on first call, returns ranked results with titles, URLs, and snippets (~849 tokens for 5 results)
326
+
290
327
  ### Batch Operations
291
328
 
292
329
  Power user tools for operating on multiple resources at once:
@@ -300,9 +337,9 @@ Power user tools for operating on multiple resources at once:
300
337
 
301
338
  - **Context-Optimized**: Responses are 90-99% smaller than raw API, preventing context window exhaustion
302
339
  - **Smart Lookup**: Find apps by domain (`stuartmason.co.uk`), servers by IP, not just UUIDs
340
+ - **Docs Search**: Built-in full-text search across Coolify documentation — your AI assistant can look up how-tos and troubleshooting without leaving the conversation
303
341
  - **Batch Operations**: Restart entire projects, bulk update env vars, emergency stop all apps
304
342
  - **Production Ready**: 98%+ test coverage, TypeScript strict mode, comprehensive error handling
305
- - **Always Current**: Weekly OpenAPI drift detection ensures the server stays in sync with Coolify
306
343
 
307
344
  ## Related Links
308
345
 
@@ -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', () => {
@@ -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
+ }
@@ -27,6 +27,7 @@ export declare function getDeploymentActions(uuid: string, status: string, appUu
27
27
  export declare function getPagination(tool: string, page?: number, perPage?: number, count?: number): ResponsePagination | undefined;
28
28
  export declare class CoolifyMcpServer extends McpServer {
29
29
  private readonly client;
30
+ private readonly docsSearch;
30
31
  constructor(config: CoolifyConfig);
31
32
  connect(transport: Transport): Promise<void>;
32
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 */
@@ -154,6 +155,7 @@ function wrapWithActions(fn, getActions, getPaginationFn) {
154
155
  export class CoolifyMcpServer extends McpServer {
155
156
  constructor(config) {
156
157
  super({ name: 'coolify', version: VERSION });
158
+ this.docsSearch = new DocsSearchEngine();
157
159
  this.client = new CoolifyClient(config);
158
160
  this.registerTools();
159
161
  }
@@ -946,6 +948,80 @@ export class CoolifyMcpServer extends McpServer {
946
948
  }
947
949
  });
948
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
+ // =========================================================================
949
1025
  // Batch Operations (4 tools)
950
1026
  // =========================================================================
951
1027
  this.tool('restart_project_apps', 'Restart all apps in project', { project_uuid: z.string() }, async ({ project_uuid }) => wrap(() => this.client.restartProjectApps(project_uuid)));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@masonator/coolify-mcp",
3
3
  "scope": "@masonator",
4
- "version": "2.6.6",
4
+ "version": "2.7.1",
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": {