@masonator/coolify-mcp 0.7.0 → 0.8.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.
@@ -71,6 +71,14 @@ function toProjectSummary(proj) {
71
71
  description: proj.description,
72
72
  };
73
73
  }
74
+ function toEnvVarSummary(envVar) {
75
+ return {
76
+ uuid: envVar.uuid,
77
+ key: envVar.key,
78
+ value: envVar.value,
79
+ is_build_time: envVar.is_build_time,
80
+ };
81
+ }
74
82
  /**
75
83
  * HTTP client for the Coolify API
76
84
  */
@@ -333,8 +341,9 @@ export class CoolifyClient {
333
341
  // ===========================================================================
334
342
  // Application Environment Variables
335
343
  // ===========================================================================
336
- async listApplicationEnvVars(uuid) {
337
- return this.request(`/applications/${uuid}/envs`);
344
+ async listApplicationEnvVars(uuid, options) {
345
+ const envVars = await this.request(`/applications/${uuid}/envs`);
346
+ return options?.summary ? envVars.map(toEnvVarSummary) : envVars;
338
347
  }
339
348
  async createApplicationEnvVar(uuid, data) {
340
349
  return this.request(`/applications/${uuid}/envs`, {
@@ -598,4 +607,381 @@ export class CoolifyClient {
598
607
  method: 'POST',
599
608
  });
600
609
  }
610
+ // ===========================================================================
611
+ // Smart Lookup Helpers
612
+ // ===========================================================================
613
+ /**
614
+ * Check if a string looks like a UUID (Coolify format or standard format).
615
+ * Coolify UUIDs are alphanumeric strings, typically 24 chars like "xs0sgs4gog044s4k4c88kgsc"
616
+ * Also accepts standard UUID format with hyphens like "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
617
+ */
618
+ isLikelyUuid(query) {
619
+ // Coolify UUID format: alphanumeric, 20+ chars
620
+ if (/^[a-z0-9]{20,}$/i.test(query)) {
621
+ return true;
622
+ }
623
+ // Standard UUID format with hyphens (8-4-4-4-12)
624
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(query)) {
625
+ return true;
626
+ }
627
+ return false;
628
+ }
629
+ /**
630
+ * Find an application by UUID, name, or domain (FQDN).
631
+ * Returns the UUID if found, throws if not found or multiple matches.
632
+ */
633
+ async resolveApplicationUuid(query) {
634
+ // If it looks like a UUID, use it directly
635
+ if (this.isLikelyUuid(query)) {
636
+ return query;
637
+ }
638
+ // Otherwise, search by name or domain
639
+ const apps = (await this.listApplications());
640
+ const queryLower = query.toLowerCase();
641
+ const matches = apps.filter((app) => {
642
+ const nameMatch = app.name?.toLowerCase().includes(queryLower);
643
+ const fqdnMatch = app.fqdn?.toLowerCase().includes(queryLower);
644
+ return nameMatch || fqdnMatch;
645
+ });
646
+ if (matches.length === 0) {
647
+ throw new Error(`No application found matching "${query}"`);
648
+ }
649
+ if (matches.length > 1) {
650
+ const matchList = matches.map((a) => `${a.name} (${a.fqdn || 'no domain'})`).join(', ');
651
+ throw new Error(`Multiple applications match "${query}": ${matchList}. Please be more specific or use a UUID.`);
652
+ }
653
+ return matches[0].uuid;
654
+ }
655
+ /**
656
+ * Find a server by UUID, name, or IP address.
657
+ * Returns the UUID if found, throws if not found or multiple matches.
658
+ */
659
+ async resolveServerUuid(query) {
660
+ // If it looks like a UUID, use it directly
661
+ if (this.isLikelyUuid(query)) {
662
+ return query;
663
+ }
664
+ // Otherwise, search by name or IP
665
+ const servers = (await this.listServers());
666
+ const queryLower = query.toLowerCase();
667
+ const matches = servers.filter((server) => {
668
+ const nameMatch = server.name?.toLowerCase().includes(queryLower);
669
+ const ipMatch = server.ip?.toLowerCase().includes(queryLower);
670
+ return nameMatch || ipMatch;
671
+ });
672
+ if (matches.length === 0) {
673
+ throw new Error(`No server found matching "${query}"`);
674
+ }
675
+ if (matches.length > 1) {
676
+ const matchList = matches.map((s) => `${s.name} (${s.ip})`).join(', ');
677
+ throw new Error(`Multiple servers match "${query}": ${matchList}. Please be more specific or use a UUID.`);
678
+ }
679
+ return matches[0].uuid;
680
+ }
681
+ // ===========================================================================
682
+ // Diagnostic endpoints (composite tools)
683
+ // ===========================================================================
684
+ /**
685
+ * Get comprehensive diagnostic info for an application.
686
+ * Aggregates: application details, logs, env vars, recent deployments.
687
+ * @param query - Application UUID, name, or domain (FQDN)
688
+ */
689
+ async diagnoseApplication(query) {
690
+ // Resolve query to UUID
691
+ let uuid;
692
+ try {
693
+ uuid = await this.resolveApplicationUuid(query);
694
+ }
695
+ catch (error) {
696
+ const msg = error instanceof Error ? error.message : String(error);
697
+ return {
698
+ application: null,
699
+ health: { status: 'unknown', issues: [] },
700
+ logs: null,
701
+ environment_variables: { count: 0, variables: [] },
702
+ recent_deployments: [],
703
+ errors: [msg],
704
+ };
705
+ }
706
+ const results = await Promise.allSettled([
707
+ this.getApplication(uuid),
708
+ this.getApplicationLogs(uuid, 50),
709
+ this.listApplicationEnvVars(uuid),
710
+ this.listApplicationDeployments(uuid),
711
+ ]);
712
+ const errors = [];
713
+ const extract = (result, name) => {
714
+ if (result.status === 'fulfilled')
715
+ return result.value;
716
+ const msg = result.reason instanceof Error ? result.reason.message : String(result.reason);
717
+ errors.push(`${name}: ${msg}`);
718
+ return null;
719
+ };
720
+ const app = extract(results[0], 'application');
721
+ const logs = extract(results[1], 'logs');
722
+ const envVars = extract(results[2], 'environment_variables');
723
+ const deployments = extract(results[3], 'deployments');
724
+ // Determine health status and issues
725
+ const issues = [];
726
+ let healthStatus = 'unknown';
727
+ if (app) {
728
+ const status = app.status || '';
729
+ if (status.includes('running') && status.includes('healthy')) {
730
+ healthStatus = 'healthy';
731
+ }
732
+ else if (status.includes('exited') ||
733
+ status.includes('unhealthy') ||
734
+ status.includes('error')) {
735
+ healthStatus = 'unhealthy';
736
+ issues.push(`Status: ${status}`);
737
+ }
738
+ else if (status.includes('running')) {
739
+ healthStatus = 'healthy';
740
+ }
741
+ else {
742
+ issues.push(`Status: ${status}`);
743
+ }
744
+ }
745
+ // Check for failed deployments
746
+ if (deployments) {
747
+ const recentFailed = deployments.slice(0, 5).filter((d) => d.status === 'failed');
748
+ if (recentFailed.length > 0) {
749
+ issues.push(`${recentFailed.length} failed deployment(s) in last 5`);
750
+ if (healthStatus === 'healthy')
751
+ healthStatus = 'unhealthy';
752
+ }
753
+ }
754
+ return {
755
+ application: app
756
+ ? {
757
+ uuid: app.uuid,
758
+ name: app.name,
759
+ status: app.status || 'unknown',
760
+ fqdn: app.fqdn || null,
761
+ git_repository: app.git_repository || null,
762
+ git_branch: app.git_branch || null,
763
+ }
764
+ : null,
765
+ health: {
766
+ status: healthStatus,
767
+ issues,
768
+ },
769
+ logs: typeof logs === 'string' ? logs : null,
770
+ environment_variables: {
771
+ count: envVars?.length || 0,
772
+ variables: (envVars || []).map((v) => ({
773
+ key: v.key,
774
+ is_build_time: v.is_build_time ?? false,
775
+ })),
776
+ },
777
+ recent_deployments: (deployments || []).slice(0, 5).map((d) => ({
778
+ uuid: d.uuid,
779
+ status: d.status,
780
+ created_at: d.created_at,
781
+ })),
782
+ ...(errors.length > 0 && { errors }),
783
+ };
784
+ }
785
+ /**
786
+ * Get comprehensive diagnostic info for a server.
787
+ * Aggregates: server details, resources, domains, validation.
788
+ * @param query - Server UUID, name, or IP address
789
+ */
790
+ async diagnoseServer(query) {
791
+ // Resolve query to UUID
792
+ let uuid;
793
+ try {
794
+ uuid = await this.resolveServerUuid(query);
795
+ }
796
+ catch (error) {
797
+ const msg = error instanceof Error ? error.message : String(error);
798
+ return {
799
+ server: null,
800
+ health: { status: 'unknown', issues: [] },
801
+ resources: [],
802
+ domains: [],
803
+ validation: null,
804
+ errors: [msg],
805
+ };
806
+ }
807
+ const results = await Promise.allSettled([
808
+ this.getServer(uuid),
809
+ this.getServerResources(uuid),
810
+ this.getServerDomains(uuid),
811
+ this.validateServer(uuid),
812
+ ]);
813
+ const errors = [];
814
+ const extract = (result, name) => {
815
+ if (result.status === 'fulfilled')
816
+ return result.value;
817
+ const msg = result.reason instanceof Error ? result.reason.message : String(result.reason);
818
+ errors.push(`${name}: ${msg}`);
819
+ return null;
820
+ };
821
+ const server = extract(results[0], 'server');
822
+ const resources = extract(results[1], 'resources');
823
+ const domains = extract(results[2], 'domains');
824
+ const validation = extract(results[3], 'validation');
825
+ // Determine health status and issues
826
+ const issues = [];
827
+ let healthStatus = 'unknown';
828
+ if (server) {
829
+ if (server.is_reachable === true) {
830
+ healthStatus = 'healthy';
831
+ }
832
+ else if (server.is_reachable === false) {
833
+ healthStatus = 'unhealthy';
834
+ issues.push('Server is not reachable');
835
+ }
836
+ if (server.is_usable === false) {
837
+ issues.push('Server is not usable');
838
+ healthStatus = 'unhealthy';
839
+ }
840
+ }
841
+ // Check for unhealthy resources
842
+ if (resources) {
843
+ const unhealthyResources = resources.filter((r) => r.status.includes('exited') ||
844
+ r.status.includes('unhealthy') ||
845
+ r.status.includes('error'));
846
+ if (unhealthyResources.length > 0) {
847
+ issues.push(`${unhealthyResources.length} unhealthy resource(s)`);
848
+ }
849
+ }
850
+ return {
851
+ server: server
852
+ ? {
853
+ uuid: server.uuid,
854
+ name: server.name,
855
+ ip: server.ip,
856
+ status: server.status || null,
857
+ is_reachable: server.is_reachable ?? null,
858
+ }
859
+ : null,
860
+ health: {
861
+ status: healthStatus,
862
+ issues,
863
+ },
864
+ resources: (resources || []).map((r) => ({
865
+ uuid: r.uuid,
866
+ name: r.name,
867
+ type: r.type,
868
+ status: r.status,
869
+ })),
870
+ domains: (domains || []).map((d) => ({
871
+ ip: d.ip,
872
+ domains: d.domains,
873
+ })),
874
+ validation: validation
875
+ ? {
876
+ message: validation.message,
877
+ ...(validation.validation_logs && { validation_logs: validation.validation_logs }),
878
+ }
879
+ : null,
880
+ ...(errors.length > 0 && { errors }),
881
+ };
882
+ }
883
+ /**
884
+ * Scan infrastructure for common issues.
885
+ * Finds: unreachable servers, unhealthy apps, exited databases, stopped services.
886
+ */
887
+ async findInfrastructureIssues() {
888
+ const results = await Promise.allSettled([
889
+ this.listServers(),
890
+ this.listApplications(),
891
+ this.listDatabases(),
892
+ this.listServices(),
893
+ ]);
894
+ const errors = [];
895
+ const issues = [];
896
+ const extract = (result, name) => {
897
+ if (result.status === 'fulfilled')
898
+ return result.value;
899
+ const msg = result.reason instanceof Error ? result.reason.message : String(result.reason);
900
+ errors.push(`${name}: ${msg}`);
901
+ return null;
902
+ };
903
+ const servers = extract(results[0], 'servers');
904
+ const applications = extract(results[1], 'applications');
905
+ const databases = extract(results[2], 'databases');
906
+ const services = extract(results[3], 'services');
907
+ // Check servers for unreachable
908
+ if (servers) {
909
+ for (const server of servers) {
910
+ if (server.is_reachable === false) {
911
+ issues.push({
912
+ type: 'server',
913
+ uuid: server.uuid,
914
+ name: server.name,
915
+ issue: 'Server is not reachable',
916
+ status: server.status || 'unreachable',
917
+ });
918
+ }
919
+ }
920
+ }
921
+ // Check applications for unhealthy status
922
+ if (applications) {
923
+ for (const app of applications) {
924
+ const status = app.status || '';
925
+ if (status.includes('exited') ||
926
+ status.includes('unhealthy') ||
927
+ status.includes('error') ||
928
+ status === 'stopped') {
929
+ issues.push({
930
+ type: 'application',
931
+ uuid: app.uuid,
932
+ name: app.name,
933
+ issue: `Application status: ${status}`,
934
+ status,
935
+ });
936
+ }
937
+ }
938
+ }
939
+ // Check databases for unhealthy status
940
+ if (databases) {
941
+ for (const db of databases) {
942
+ const status = db.status || '';
943
+ if (status.includes('exited') ||
944
+ status.includes('unhealthy') ||
945
+ status.includes('error') ||
946
+ status === 'stopped') {
947
+ issues.push({
948
+ type: 'database',
949
+ uuid: db.uuid,
950
+ name: db.name,
951
+ issue: `Database status: ${status}`,
952
+ status,
953
+ });
954
+ }
955
+ }
956
+ }
957
+ // Check services for unhealthy status
958
+ if (services) {
959
+ for (const svc of services) {
960
+ const status = svc.status || '';
961
+ if (status.includes('exited') ||
962
+ status.includes('unhealthy') ||
963
+ status.includes('error') ||
964
+ status === 'stopped') {
965
+ issues.push({
966
+ type: 'service',
967
+ uuid: svc.uuid,
968
+ name: svc.name,
969
+ issue: `Service status: ${status}`,
970
+ status,
971
+ });
972
+ }
973
+ }
974
+ }
975
+ return {
976
+ summary: {
977
+ total_issues: issues.length,
978
+ unhealthy_applications: issues.filter((i) => i.type === 'application').length,
979
+ unhealthy_databases: issues.filter((i) => i.type === 'database').length,
980
+ unhealthy_services: issues.filter((i) => i.type === 'service').length,
981
+ unreachable_servers: issues.filter((i) => i.type === 'server').length,
982
+ },
983
+ issues,
984
+ ...(errors.length > 0 && { errors }),
985
+ };
986
+ }
601
987
  }
@@ -20,7 +20,7 @@
20
20
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
21
21
  import { z } from 'zod';
22
22
  import { CoolifyClient, } from './coolify-client.js';
23
- const VERSION = '0.7.0';
23
+ const VERSION = '0.8.1';
24
24
  /** Wrap tool handler with consistent error handling */
25
25
  function wrapHandler(fn) {
26
26
  return fn()
@@ -193,7 +193,7 @@ export class CoolifyMcpServer extends McpServer {
193
193
  lines: z.number().optional().describe('Number of lines'),
194
194
  }, async ({ uuid, lines }) => wrapHandler(() => this.client.getApplicationLogs(uuid, lines)));
195
195
  // Application env vars
196
- this.tool('list_application_envs', 'List application environment variables', { uuid: z.string().describe('Application UUID') }, async ({ uuid }) => wrapHandler(() => this.client.listApplicationEnvVars(uuid)));
196
+ this.tool('list_application_envs', 'List application environment variables (returns summary: uuid, key, value, is_build_time)', { uuid: z.string().describe('Application UUID') }, async ({ uuid }) => wrapHandler(() => this.client.listApplicationEnvVars(uuid, { summary: true })));
197
197
  this.tool('create_application_env', 'Create application environment variable', {
198
198
  uuid: z.string().describe('Application UUID'),
199
199
  key: z.string().describe('Variable key'),
@@ -341,5 +341,11 @@ export class CoolifyMcpServer extends McpServer {
341
341
  backup_uuid: z.string().describe('Scheduled backup UUID'),
342
342
  execution_uuid: z.string().describe('Backup execution UUID'),
343
343
  }, async ({ database_uuid, backup_uuid, execution_uuid }) => wrapHandler(() => this.client.getBackupExecution(database_uuid, backup_uuid, execution_uuid)));
344
+ // =========================================================================
345
+ // Diagnostics (3 tools) - Composite tools for debugging
346
+ // =========================================================================
347
+ this.tool('diagnose_app', 'Get comprehensive diagnostic info for an application. Accepts UUID, name, or domain (e.g., "stuartmason.co.uk" or "my-app"). Aggregates: status, health assessment, logs (last 50 lines), environment variables (keys only, values hidden), and recent deployments. Use this for debugging application issues.', { query: z.string().describe('Application UUID, name, or domain (FQDN)') }, async ({ query }) => wrapHandler(() => this.client.diagnoseApplication(query)));
348
+ this.tool('diagnose_server', 'Get comprehensive diagnostic info for a server. Accepts UUID, name, or IP address (e.g., "coolify-apps" or "192.168.1.100"). Aggregates: server status, health assessment, running resources, configured domains, and connection validation. Use this for debugging server issues.', { query: z.string().describe('Server UUID, name, or IP address') }, async ({ query }) => wrapHandler(() => this.client.diagnoseServer(query)));
349
+ this.tool('find_issues', 'Scan entire infrastructure for common issues. Finds: unreachable servers, unhealthy/stopped applications, exited databases, and stopped services. Returns a summary with issue counts and detailed list of problems.', {}, async () => wrapHandler(() => this.client.findInfrastructureIssues()));
344
350
  }
345
351
  }
@@ -348,6 +348,12 @@ export interface UpdateEnvVarRequest {
348
348
  export interface BulkUpdateEnvVarsRequest {
349
349
  data: CreateEnvVarRequest[];
350
350
  }
351
+ export interface EnvVarSummary {
352
+ uuid: string;
353
+ key: string;
354
+ value: string;
355
+ is_build_time: boolean;
356
+ }
351
357
  export type DatabaseType = 'postgresql' | 'mysql' | 'mariadb' | 'mongodb' | 'redis' | 'keydb' | 'clickhouse' | 'dragonfly';
352
358
  export interface DatabaseLimits {
353
359
  memory?: string;
@@ -653,3 +659,78 @@ export interface HealthCheck {
653
659
  status: 'healthy' | 'unhealthy';
654
660
  version?: string;
655
661
  }
662
+ export type DiagnosticHealthStatus = 'healthy' | 'unhealthy' | 'unknown';
663
+ export interface ApplicationDiagnostic {
664
+ application: {
665
+ uuid: string;
666
+ name: string;
667
+ status: string;
668
+ fqdn: string | null;
669
+ git_repository: string | null;
670
+ git_branch: string | null;
671
+ } | null;
672
+ health: {
673
+ status: DiagnosticHealthStatus;
674
+ issues: string[];
675
+ };
676
+ logs: string | null;
677
+ environment_variables: {
678
+ count: number;
679
+ variables: Array<{
680
+ key: string;
681
+ is_build_time: boolean;
682
+ }>;
683
+ };
684
+ recent_deployments: Array<{
685
+ uuid: string;
686
+ status: string;
687
+ created_at: string;
688
+ }>;
689
+ errors?: string[];
690
+ }
691
+ export interface ServerDiagnostic {
692
+ server: {
693
+ uuid: string;
694
+ name: string;
695
+ ip: string;
696
+ status: string | null;
697
+ is_reachable: boolean | null;
698
+ } | null;
699
+ health: {
700
+ status: DiagnosticHealthStatus;
701
+ issues: string[];
702
+ };
703
+ resources: Array<{
704
+ uuid: string;
705
+ name: string;
706
+ type: string;
707
+ status: string;
708
+ }>;
709
+ domains: Array<{
710
+ ip: string;
711
+ domains: string[];
712
+ }>;
713
+ validation: {
714
+ message: string;
715
+ validation_logs?: string;
716
+ } | null;
717
+ errors?: string[];
718
+ }
719
+ export interface InfrastructureIssue {
720
+ type: 'application' | 'database' | 'service' | 'server';
721
+ uuid: string;
722
+ name: string;
723
+ issue: string;
724
+ status: string;
725
+ }
726
+ export interface InfrastructureIssuesReport {
727
+ summary: {
728
+ total_issues: number;
729
+ unhealthy_applications: number;
730
+ unhealthy_databases: number;
731
+ unhealthy_services: number;
732
+ unreachable_servers: number;
733
+ };
734
+ issues: InfrastructureIssue[];
735
+ errors?: string[];
736
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@masonator/coolify-mcp",
3
3
  "scope": "@masonator",
4
- "version": "0.7.0",
4
+ "version": "0.8.1",
5
5
  "description": "MCP server implementation for Coolify",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -21,9 +21,10 @@
21
21
  "scripts": {
22
22
  "build": "tsc && shx chmod +x dist/*.js",
23
23
  "dev": "tsc --watch",
24
- "test": "NODE_OPTIONS=--experimental-vm-modules jest",
25
- "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch",
26
- "test:coverage": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
24
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns=integration",
25
+ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch --testPathIgnorePatterns=integration",
26
+ "test:coverage": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --testPathIgnorePatterns=integration",
27
+ "test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --testPathPattern=integration --testTimeout=60000",
27
28
  "lint": "eslint . --ext .ts",
28
29
  "lint:fix": "eslint . --ext .ts --fix",
29
30
  "format": "prettier --write .",
@@ -39,6 +40,10 @@
39
40
  ],
40
41
  "author": "Stuart Mason",
41
42
  "license": "MIT",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "https://github.com/StuMason/coolify-mcp.git"
46
+ },
42
47
  "dependencies": {
43
48
  "@modelcontextprotocol/sdk": "^1.6.1",
44
49
  "zod": "^3.24.2"
@@ -48,6 +53,7 @@
48
53
  "@types/node": "^20.17.23",
49
54
  "@typescript-eslint/eslint-plugin": "^7.18.0",
50
55
  "@typescript-eslint/parser": "^7.18.0",
56
+ "dotenv": "^16.4.5",
51
57
  "eslint": "^8.56.0",
52
58
  "eslint-config-prettier": "^9.1.0",
53
59
  "husky": "^9.0.11",