@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 +4 -1
- package/dist/__tests__/coolify-client.test.js +41 -0
- package/dist/__tests__/docs-search.test.d.ts +1 -0
- package/dist/__tests__/docs-search.test.js +220 -0
- package/dist/__tests__/mcp-server.test.js +15 -0
- package/dist/lib/coolify-client.d.ts +2 -0
- package/dist/lib/coolify-client.js +9 -1
- package/dist/lib/docs-search.d.ts +32 -0
- package/dist/lib/docs-search.js +152 -0
- package/dist/lib/mcp-server.d.ts +1 -0
- package/dist/lib/mcp-server.js +76 -0
- package/package.json +2 -1
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 **
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/lib/mcp-server.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/mcp-server.js
CHANGED
|
@@ -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.
|
|
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": {
|