@masonator/coolify-mcp
Advanced tools
| import { describe, it, expect } from '@jest/globals'; | ||
| import { DocsSearchEngine } from '../../lib/docs-search.js'; | ||
| /** | ||
| * Live-format canary. The docs search engine indexes coolify.io/docs/llms.txt, | ||
| * a file Coolify can reshape without notice — the previous implementation was | ||
| * silently dead in production for weeks after exactly such a change, because | ||
| * unit tests only ever see a fixture frozen in the old format. This suite | ||
| * fetches the real file: if upstream changes shape, this fails in CI instead | ||
| * of users' clients. Needs the network, nothing else — no Coolify credentials. | ||
| */ | ||
| describe('docs search against the live index', () => { | ||
| it('parses a sane number of entries from the real llms.txt', async () => { | ||
| const engine = new DocsSearchEngine(); | ||
| await engine.ensureLoaded(); | ||
| // ~300 pages at the time of writing. 100 is the tripwire, not the target: | ||
| // low enough to survive upstream pruning, high enough that a format | ||
| // change (which yields zero) can never sneak under it. | ||
| expect(engine.getEntryCount()).toBeGreaterThan(100); | ||
| }, 30_000); | ||
| it('answers the README example question with a relevant page', async () => { | ||
| const engine = new DocsSearchEngine(); | ||
| const results = await engine.search('502 bad gateway'); | ||
| expect(results.length).toBeGreaterThan(0); | ||
| expect(results[0].url).toMatch(/^https:\/\/coolify\.io\/docs\//); | ||
| }, 30_000); | ||
| }); |
| import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; | ||
| import { DocsSearchEngine, parseDocs } from '../lib/docs-search.js'; | ||
| // Sample llms-full.txt content for testing | ||
| const SAMPLE_DOCS = `--- | ||
| url: /docs/get-started/installation.md | ||
| description: >- | ||
| Install Coolify self-hosted PaaS on Linux servers with automated Docker setup | ||
| script and SSH access. | ||
| --- | ||
| import { DocsSearchEngine, parseDocsIndex } from '../lib/docs-search.js'; | ||
| // Sample llms.txt content: a markdown link list with section labels, exactly | ||
| // the shape coolify.io/docs/llms.txt serves. | ||
| const SAMPLE_INDEX = `# Docs | ||
| # Installation | ||
| - [Coolify](/): Coolify is an open-source Platform as a Service. | ||
| - Get Started | ||
| Coolify can be installed on any Linux server. | ||
| - **Setup** | ||
| - [Introduction](/get-started/introduction): Coolify is an open-source self-hosted PaaS alternative. | ||
| - [Installation](/get-started/installation): Install Coolify on Linux servers with the automated setup script. | ||
| - [Upgrading](/get-started/upgrade): Upgrade self-hosted Coolify automatically or manually. | ||
| ## Requirements | ||
| You need a server with at least 2GB RAM and 2 CPU cores. | ||
| SSH access is required for the installation process. | ||
| ## Quick Install | ||
| Run the following command to install Coolify: | ||
| \`\`\`bash | ||
| curl -fsSL https://cdn.coolify.io/install.sh | bash | ||
| \`\`\` | ||
| --- | ||
| --- | ||
| url: /docs/applications/docker-compose.md | ||
| description: >- | ||
| Deploy Docker Compose applications on Coolify with environment variables, | ||
| build packs, and custom domains. | ||
| --- | ||
| # Docker Compose | ||
| You can deploy any Docker Compose based application with Coolify. | ||
| ## Environment Variables | ||
| Define environment variables in your docker-compose.yml or through the Coolify UI. | ||
| Variables defined in the UI take precedence over those in the compose file. | ||
| ## Custom Domains | ||
| Set custom domains for your Docker Compose services through the Coolify dashboard. | ||
| Each service can have its own domain configuration. | ||
| --- | ||
| --- | ||
| url: /docs/troubleshoot/applications/502-error.md | ||
| description: >- | ||
| Fix 502 Bad Gateway errors in Coolify applications caused by health check | ||
| failures, port mismatches, and proxy configuration issues. | ||
| --- | ||
| # 502 Bad Gateway Error | ||
| A 502 error usually means your application is not responding to the reverse proxy. | ||
| ## Common Causes | ||
| Check the following: | ||
| - Your application is listening on the correct port | ||
| - Health checks are configured properly | ||
| - The container is actually running | ||
| ## Port Configuration | ||
| Make sure your application listens on the port specified in the Coolify settings. | ||
| The default exposed port is 3000 for most build packs.`; | ||
| describe('parseDocs', () => { | ||
| it('should parse pages from llms-full.txt format', () => { | ||
| const chunks = parseDocs(SAMPLE_DOCS); | ||
| expect(chunks.length).toBeGreaterThan(0); | ||
| - **Learn** | ||
| - [Concepts](/get-started/concepts): Learn core Coolify concepts including servers and projects. | ||
| - Applications | ||
| - [Applications](/applications): Deploy web applications with build packs and environment variables. | ||
| - [Docker Compose](/applications/docker-compose): Deploy Docker Compose applications with custom domains. | ||
| - [External link](https://example.com/page): A fully-qualified URL passes through untouched. | ||
| - [No description](/bare-link) | ||
| `; | ||
| describe('parseDocsIndex', () => { | ||
| it('parses link items with titles, urls and descriptions', () => { | ||
| const entries = parseDocsIndex(SAMPLE_INDEX); | ||
| const install = entries.find((e) => e.title === 'Installation'); | ||
| expect(install).toBeDefined(); | ||
| expect(install.url).toBe('https://coolify.io/docs/get-started/installation'); | ||
| expect(install.description).toContain('automated setup script'); | ||
| }); | ||
| it('should extract title, url, and description from frontmatter', () => { | ||
| const chunks = parseDocs(SAMPLE_DOCS); | ||
| const installChunk = chunks.find((c) => c.title === 'Installation'); | ||
| expect(installChunk).toBeDefined(); | ||
| expect(installChunk.url).toBe('https://coolify.io/docs/get-started/installation'); | ||
| expect(installChunk.description).toContain('Install Coolify'); | ||
| it('tracks the nearest section label for each entry', () => { | ||
| const entries = parseDocsIndex(SAMPLE_INDEX); | ||
| expect(entries.find((e) => e.title === 'Installation').section).toBe('Setup'); | ||
| expect(entries.find((e) => e.title === 'Concepts').section).toBe('Learn'); | ||
| expect(entries.find((e) => e.title === 'Docker Compose').section).toBe('Applications'); | ||
| }); | ||
| it('should split pages into sub-chunks at ## headers', () => { | ||
| const chunks = parseDocs(SAMPLE_DOCS); | ||
| const subChunks = chunks.filter((c) => c.title.includes('>')); | ||
| expect(subChunks.length).toBeGreaterThan(0); | ||
| expect(subChunks.some((c) => c.title.includes('Requirements'))).toBe(true); | ||
| it('passes absolute URLs through untouched', () => { | ||
| const entries = parseDocsIndex(SAMPLE_INDEX); | ||
| expect(entries.find((e) => e.title === 'External link').url).toBe('https://example.com/page'); | ||
| }); | ||
| it('should strip .md extension from URLs', () => { | ||
| const chunks = parseDocs(SAMPLE_DOCS); | ||
| chunks.forEach((c) => { | ||
| expect(c.url).not.toContain('.md'); | ||
| }); | ||
| it('accepts link items with no description', () => { | ||
| const entries = parseDocsIndex(SAMPLE_INDEX); | ||
| const bare = entries.find((e) => e.title === 'No description'); | ||
| expect(bare).toBeDefined(); | ||
| expect(bare.description).toBe(''); | ||
| }); | ||
| it('should handle empty input', () => { | ||
| const chunks = parseDocs(''); | ||
| expect(chunks).toEqual([]); | ||
| it('does not double-prefix paths that already carry /docs', () => { | ||
| const entries = parseDocsIndex('- [Authorization](/docs/api-reference/authorization): Bearer tokens.'); | ||
| expect(entries[0].url).toBe('https://coolify.io/docs/api-reference/authorization'); | ||
| }); | ||
| it('should assign sequential IDs', () => { | ||
| const chunks = parseDocs(SAMPLE_DOCS); | ||
| chunks.forEach((chunk, index) => { | ||
| expect(chunk.id).toBe(index); | ||
| }); | ||
| it('returns zero entries for content with no link items', () => { | ||
| // The old llms-full.txt frontmatter format is exactly this case — the | ||
| // engine must treat it as a hard error, which the engine tests pin. | ||
| expect(parseDocsIndex('---\nurl: /docs/x.md\ndescription: y\n---\n\n# X\n\nBody.')).toEqual([]); | ||
| }); | ||
| }); | ||
| describe('DocsSearchEngine', () => { | ||
| let mockFetch; | ||
| let engine; | ||
| let mockFetch; | ||
| beforeEach(() => { | ||
@@ -120,102 +67,40 @@ engine = new DocsSearchEngine(); | ||
| }); | ||
| it('should fetch and index docs on first search', async () => { | ||
| mockFetch.mockResolvedValueOnce({ | ||
| ok: true, | ||
| text: async () => SAMPLE_DOCS, | ||
| }); | ||
| const results = await engine.search('installation'); | ||
| expect(results.length).toBeGreaterThan(0); | ||
| const okResponse = (body) => ({ ok: true, text: async () => body }); | ||
| it('fetches and indexes the docs index on first search only', async () => { | ||
| mockFetch.mockResolvedValueOnce(okResponse(SAMPLE_INDEX)); | ||
| await engine.search('install'); | ||
| await engine.search('compose'); | ||
| expect(mockFetch).toHaveBeenCalledTimes(1); | ||
| expect(engine.getEntryCount()).toBeGreaterThan(5); | ||
| }); | ||
| it('should deduplicate concurrent loading', async () => { | ||
| mockFetch.mockResolvedValueOnce({ | ||
| ok: true, | ||
| text: async () => SAMPLE_DOCS, | ||
| }); | ||
| const [results1, results2] = await Promise.all([ | ||
| engine.search('installation'), | ||
| engine.search('docker'), | ||
| ]); | ||
| expect(mockFetch).toHaveBeenCalledTimes(1); | ||
| expect(results1.length).toBeGreaterThan(0); | ||
| expect(results2.length).toBeGreaterThan(0); | ||
| it('ranks the obviously right page first', async () => { | ||
| mockFetch.mockResolvedValueOnce(okResponse(SAMPLE_INDEX)); | ||
| const results = await engine.search('installation'); | ||
| expect(results[0].title).toBe('Installation'); | ||
| expect(results[0].url).toBe('https://coolify.io/docs/get-started/installation'); | ||
| expect(results[0].score).toBeGreaterThan(0); | ||
| }); | ||
| it('should only fetch once across multiple searches', async () => { | ||
| mockFetch.mockResolvedValueOnce({ | ||
| ok: true, | ||
| text: async () => SAMPLE_DOCS, | ||
| }); | ||
| await engine.search('installation'); | ||
| await engine.search('docker compose'); | ||
| expect(mockFetch).toHaveBeenCalledTimes(1); | ||
| }); | ||
| it('should return results with title, url, description, snippet, score', async () => { | ||
| mockFetch.mockResolvedValueOnce({ | ||
| ok: true, | ||
| text: async () => SAMPLE_DOCS, | ||
| }); | ||
| const results = await engine.search('502 error'); | ||
| expect(results.length).toBeGreaterThan(0); | ||
| const r = results[0]; | ||
| expect(r).toHaveProperty('title'); | ||
| expect(r).toHaveProperty('url'); | ||
| expect(r).toHaveProperty('description'); | ||
| expect(r).toHaveProperty('snippet'); | ||
| expect(r).toHaveProperty('score'); | ||
| expect(typeof r.score).toBe('number'); | ||
| }); | ||
| it('should rank relevant results higher', async () => { | ||
| mockFetch.mockResolvedValueOnce({ | ||
| ok: true, | ||
| text: async () => SAMPLE_DOCS, | ||
| }); | ||
| const results = await engine.search('docker compose environment variables'); | ||
| expect(results[0].url).toContain('docker-compose'); | ||
| }); | ||
| it('should return empty array for no matches', async () => { | ||
| mockFetch.mockResolvedValueOnce({ | ||
| ok: true, | ||
| text: async () => SAMPLE_DOCS, | ||
| }); | ||
| const results = await engine.search('xyznonexistent12345'); | ||
| expect(results).toEqual([]); | ||
| }); | ||
| it('should respect limit parameter', async () => { | ||
| mockFetch.mockResolvedValueOnce({ | ||
| ok: true, | ||
| text: async () => SAMPLE_DOCS, | ||
| }); | ||
| it('respects the limit parameter', async () => { | ||
| mockFetch.mockResolvedValueOnce(okResponse(SAMPLE_INDEX)); | ||
| const results = await engine.search('coolify', 2); | ||
| expect(results.length).toBeLessThanOrEqual(2); | ||
| }); | ||
| it('should throw on fetch failure', async () => { | ||
| mockFetch.mockResolvedValueOnce({ | ||
| ok: false, | ||
| status: 500, | ||
| }); | ||
| await expect(engine.search('test')).rejects.toThrow('Failed to fetch Coolify docs'); | ||
| }); | ||
| it('should retry after fetch failure', async () => { | ||
| mockFetch.mockResolvedValueOnce({ | ||
| ok: false, | ||
| status: 500, | ||
| }); | ||
| await expect(engine.search('test')).rejects.toThrow(); | ||
| // Second attempt should try fetching again (loading was reset) | ||
| mockFetch.mockResolvedValueOnce({ | ||
| ok: true, | ||
| text: async () => SAMPLE_DOCS, | ||
| }); | ||
| const results = await engine.search('installation'); | ||
| it('throws when the fetch fails, and recovers on the next call', async () => { | ||
| mockFetch.mockRejectedValueOnce(new Error('network down')); | ||
| await expect(engine.search('install')).rejects.toThrow('network down'); | ||
| mockFetch.mockResolvedValueOnce(okResponse(SAMPLE_INDEX)); | ||
| const results = await engine.search('install'); | ||
| expect(results.length).toBeGreaterThan(0); | ||
| }); | ||
| it('should report chunk count after loading', async () => { | ||
| mockFetch.mockResolvedValueOnce({ | ||
| ok: true, | ||
| text: async () => SAMPLE_DOCS, | ||
| }); | ||
| expect(engine.getChunkCount()).toBe(0); | ||
| await engine.search('test'); | ||
| expect(engine.getChunkCount()).toBeGreaterThan(0); | ||
| it('throws on a non-OK response', async () => { | ||
| mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); | ||
| await expect(engine.search('install')).rejects.toThrow('HTTP 404'); | ||
| }); | ||
| it('treats an index that parses to zero entries as an error, not an empty result', async () => { | ||
| // This is the regression test for the silent failure: the previous | ||
| // implementation indexed zero chunks from a changed upstream format and | ||
| // returned [] for every query, indefinitely, with no error. | ||
| mockFetch.mockResolvedValueOnce(okResponse('---\nurl: /docs/x.md\n---\n\n# Old format\n')); | ||
| await expect(engine.search('anything')).rejects.toThrow(/zero entries/); | ||
| }); | ||
| }); |
@@ -1,2 +0,2 @@ | ||
| interface DocChunk { | ||
| interface DocEntry { | ||
| id: number; | ||
@@ -6,3 +6,3 @@ title: string; | ||
| description: string; | ||
| content: string; | ||
| section: string; | ||
| } | ||
@@ -13,13 +13,20 @@ export interface DocSearchResult { | ||
| description: string; | ||
| snippet: string; | ||
| section: string; | ||
| score: number; | ||
| } | ||
| /** | ||
| * Lightweight full-text search over Coolify documentation. | ||
| * Fetches llms-full.txt on first search, parses into chunks, indexes with MiniSearch (BM25). | ||
| * The LLM calling this tool handles semantic understanding — we just need good ranking. | ||
| * Search over the official Coolify docs index (llms.txt). | ||
| * | ||
| * This used to fetch llms-full.txt — the ~40MB full-content dump — and run a | ||
| * bespoke frontmatter parser over it. Coolify changed that file's format and | ||
| * the parser silently produced zero chunks: the index "loaded", every search | ||
| * returned an empty result, and nothing errored. llms.txt is the better | ||
| * corpus anyway: ~46KB, a stable spec'd shape (a markdown link list), and | ||
| * every page comes with a human-written one-line description. The tool's job | ||
| * is routing the model to the right page, not serving snippets — the caller | ||
| * can fetch the page itself for depth. | ||
| */ | ||
| export declare class DocsSearchEngine { | ||
| private index; | ||
| private chunks; | ||
| private entries; | ||
| private loading; | ||
@@ -29,7 +36,17 @@ ensureLoaded(): Promise<void>; | ||
| search(query: string, limit?: number): Promise<DocSearchResult[]>; | ||
| private getSnippet; | ||
| getChunkCount(): number; | ||
| getEntryCount(): number; | ||
| } | ||
| /** Parse llms-full.txt into doc chunks. Exported for testing. */ | ||
| export declare function parseDocs(text: string): DocChunk[]; | ||
| /** | ||
| * Parse llms.txt — a markdown link list — into doc entries. | ||
| * Exported for testing. | ||
| * | ||
| * The shape, per the llms.txt convention: | ||
| * - Plain list items and bold items ("- Get Started", " - **Setup**") are | ||
| * section labels for the links nested under them. | ||
| * - Link items carry the page: "- [Title](/path): one-line description". | ||
| * The description after the colon is optional; paths are relative to the | ||
| * docs root (the site serves them under /docs), and absolute URLs pass | ||
| * through untouched. | ||
| */ | ||
| export declare function parseDocsIndex(text: string): DocEntry[]; | ||
| export {}; |
+70
-90
| import MiniSearch from 'minisearch'; | ||
| const DOCS_FULL_URL = 'https://coolify.io/docs/llms-full.txt'; | ||
| const DOCS_BASE_URL = 'https://coolify.io'; | ||
| const DOCS_INDEX_URL = 'https://coolify.io/docs/llms.txt'; | ||
| const DOCS_BASE_URL = 'https://coolify.io/docs'; | ||
| /** | ||
| * Lightweight full-text search over Coolify documentation. | ||
| * Fetches llms-full.txt on first search, parses into chunks, indexes with MiniSearch (BM25). | ||
| * The LLM calling this tool handles semantic understanding — we just need good ranking. | ||
| * Search over the official Coolify docs index (llms.txt). | ||
| * | ||
| * This used to fetch llms-full.txt — the ~40MB full-content dump — and run a | ||
| * bespoke frontmatter parser over it. Coolify changed that file's format and | ||
| * the parser silently produced zero chunks: the index "loaded", every search | ||
| * returned an empty result, and nothing errored. llms.txt is the better | ||
| * corpus anyway: ~46KB, a stable spec'd shape (a markdown link list), and | ||
| * every page comes with a human-written one-line description. The tool's job | ||
| * is routing the model to the right page, not serving snippets — the caller | ||
| * can fetch the page itself for depth. | ||
| */ | ||
| export class DocsSearchEngine { | ||
| index = null; | ||
| chunks = []; | ||
| entries = []; | ||
| loading = null; | ||
@@ -27,3 +34,3 @@ async ensureLoaded() { | ||
| try { | ||
| response = await fetch(DOCS_FULL_URL, { signal: controller.signal }); | ||
| response = await fetch(DOCS_INDEX_URL, { signal: controller.signal }); | ||
| } | ||
@@ -34,11 +41,18 @@ finally { | ||
| if (!response.ok) { | ||
| throw new Error(`Failed to fetch Coolify docs: HTTP ${response.status}`); | ||
| throw new Error(`Failed to fetch Coolify docs index: HTTP ${response.status}`); | ||
| } | ||
| const text = await response.text(); | ||
| this.chunks = parseDocs(text); | ||
| this.entries = parseDocsIndex(text); | ||
| // Zero entries from a 200 response means the format changed, not that | ||
| // the docs are empty. Fail loudly — a silently empty index is exactly | ||
| // the failure mode that let the previous implementation stay broken in | ||
| // production unnoticed. | ||
| if (this.entries.length === 0) { | ||
| throw new Error('Parsed zero entries from the Coolify docs index — llms.txt format may have changed'); | ||
| } | ||
| this.index = new MiniSearch({ | ||
| fields: ['title', 'description', 'content'], | ||
| storeFields: ['title', 'url', 'description'], | ||
| fields: ['title', 'description', 'section'], | ||
| storeFields: ['title', 'url', 'description', 'section'], | ||
| searchOptions: { | ||
| boost: { title: 3, description: 2, content: 1 }, | ||
| boost: { title: 3, description: 1, section: 1 }, | ||
| prefix: true, | ||
@@ -48,3 +62,3 @@ fuzzy: 0.2, | ||
| }); | ||
| this.index.addAll(this.chunks); | ||
| this.index.addAll(this.entries); | ||
| } | ||
@@ -54,3 +68,3 @@ catch (error) { | ||
| this.index = null; | ||
| this.chunks = []; | ||
| this.entries = []; | ||
| throw error; | ||
@@ -69,87 +83,53 @@ } | ||
| description: r.description, | ||
| snippet: this.getSnippet(r.id, query), | ||
| section: r.section, | ||
| score: Math.round(r.score * 100) / 100, | ||
| })); | ||
| } | ||
| getSnippet(id, query) { | ||
| const chunk = this.chunks[id]; | ||
| if (!chunk) | ||
| return ''; | ||
| const content = chunk.content; | ||
| const queryTerms = query.toLowerCase().split(/\s+/); | ||
| // Find best position — where query terms appear | ||
| let bestPos = 0; | ||
| let bestScore = -1; | ||
| const lower = content.toLowerCase(); | ||
| for (let i = 0; i < lower.length - 100; i += 50) { | ||
| const window = lower.slice(i, i + 300); | ||
| const score = queryTerms.reduce((s, t) => s + (window.includes(t) ? 1 : 0), 0); | ||
| if (score > bestScore) { | ||
| bestScore = score; | ||
| bestPos = i; | ||
| } | ||
| } | ||
| const start = Math.max(0, bestPos); | ||
| const end = Math.min(content.length, start + 300); | ||
| let snippet = content.slice(start, end).trim(); | ||
| if (start > 0) | ||
| snippet = '...' + snippet; | ||
| if (end < content.length) | ||
| snippet = snippet + '...'; | ||
| return snippet; | ||
| getEntryCount() { | ||
| return this.entries.length; | ||
| } | ||
| getChunkCount() { | ||
| return this.chunks.length; | ||
| } | ||
| } | ||
| /** Parse llms-full.txt into doc chunks. Exported for testing. */ | ||
| export function parseDocs(text) { | ||
| const chunks = []; | ||
| // Split on page boundaries: ---\n\n--- or end of frontmatter pairs | ||
| // Each page starts with ---\nurl: ...\ndescription: ...\n---\n then markdown | ||
| const pages = text.split(/\n---\n\n---\n/); | ||
| for (const page of pages) { | ||
| const parsed = parsePage(page); | ||
| if (!parsed) | ||
| /** | ||
| * Parse llms.txt — a markdown link list — into doc entries. | ||
| * Exported for testing. | ||
| * | ||
| * The shape, per the llms.txt convention: | ||
| * - Plain list items and bold items ("- Get Started", " - **Setup**") are | ||
| * section labels for the links nested under them. | ||
| * - Link items carry the page: "- [Title](/path): one-line description". | ||
| * The description after the colon is optional; paths are relative to the | ||
| * docs root (the site serves them under /docs), and absolute URLs pass | ||
| * through untouched. | ||
| */ | ||
| export function parseDocsIndex(text) { | ||
| const entries = []; | ||
| let section = ''; | ||
| for (const line of text.split('\n')) { | ||
| const link = line.match(/^\s*-\s*\[([^\]]+)\]\(([^)\s]+)\)(?::\s*(.*))?\s*$/); | ||
| if (link) { | ||
| const [, title, path, description] = link; | ||
| entries.push({ | ||
| id: entries.length, | ||
| title: title.trim(), | ||
| url: buildUrl(path.trim()), | ||
| description: (description ?? '').trim(), | ||
| section, | ||
| }); | ||
| continue; | ||
| // Split large pages into sub-chunks at ## headers | ||
| const sections = parsed.content.split(/\n(?=## )/); | ||
| for (const section of sections) { | ||
| const trimmed = section.trim(); | ||
| if (!trimmed || trimmed.length < 20) | ||
| continue; | ||
| // Extract section title if present | ||
| const sectionTitle = trimmed.match(/^## (.+)/)?.[1]; | ||
| const title = sectionTitle ? `${parsed.title} > ${sectionTitle}` : parsed.title; | ||
| chunks.push({ | ||
| id: chunks.length, | ||
| title, | ||
| url: parsed.url, | ||
| description: parsed.description, | ||
| content: trimmed.replace(/^## .+\n/, '').trim(), | ||
| }); | ||
| } | ||
| // A list item that is not a link is a section label; so is a heading. | ||
| const label = line.match(/^\s*-\s*\*\*(.+?)\*\*\s*$/) ?? | ||
| line.match(/^\s*-\s+([^[\s].*?)\s*$/) ?? | ||
| line.match(/^#+\s+(.+?)\s*$/); | ||
| if (label) | ||
| section = label[1]; | ||
| } | ||
| return chunks; | ||
| return entries; | ||
| } | ||
| function parsePage(raw) { | ||
| // Handle frontmatter — may start with --- or just url: | ||
| const frontmatterMatch = raw.match(/(?:---\n)?url:\s*(.+)\ndescription:\s*>?-?\n?([\s\S]*?)\n---\n([\s\S]*)/); | ||
| if (!frontmatterMatch) | ||
| return null; | ||
| const urlPath = frontmatterMatch[1].trim(); | ||
| const description = frontmatterMatch[2] | ||
| .split('\n') | ||
| .map((l) => l.trim()) | ||
| .join(' ') | ||
| .trim(); | ||
| const content = frontmatterMatch[3].trim(); | ||
| // Extract H1 title from content | ||
| const titleMatch = content.match(/^#\s+(.+)/m); | ||
| const title = titleMatch?.[1] || urlPath; | ||
| // Build full URL | ||
| const url = urlPath.endsWith('.md') | ||
| ? DOCS_BASE_URL + urlPath.replace(/\.md$/, '') | ||
| : DOCS_BASE_URL + urlPath; | ||
| return { url, description, title, content }; | ||
| function buildUrl(path) { | ||
| if (/^https?:\/\//.test(path)) | ||
| return path; | ||
| if (path.startsWith('/docs/') || path === '/docs') | ||
| return `https://coolify.io${path}`; | ||
| return `${DOCS_BASE_URL}${path.startsWith('/') ? '' : '/'}${path}`; | ||
| } |
+1
-1
| { | ||
| "name": "@masonator/coolify-mcp", | ||
| "scope": "@masonator", | ||
| "version": "2.19.0", | ||
| "version": "2.19.1", | ||
| "mcpName": "io.github.StuMason/coolify", | ||
@@ -6,0 +6,0 @@ "description": "MCP server for Coolify — 44 optimized tools for infrastructure management, diagnostics, and documentation search", |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
797059
0.1%43
4.88%21
-4.55%15811
-0.37%