@masonator/coolify-mcp 2.6.6 → 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', () => {
@@ -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.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": {