@masonator/coolify-mcp 0.7.0 → 0.8.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.
@@ -598,4 +598,381 @@ export class CoolifyClient {
598
598
  method: 'POST',
599
599
  });
600
600
  }
601
+ // ===========================================================================
602
+ // Smart Lookup Helpers
603
+ // ===========================================================================
604
+ /**
605
+ * Check if a string looks like a UUID (Coolify format or standard format).
606
+ * Coolify UUIDs are alphanumeric strings, typically 24 chars like "xs0sgs4gog044s4k4c88kgsc"
607
+ * Also accepts standard UUID format with hyphens like "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
608
+ */
609
+ isLikelyUuid(query) {
610
+ // Coolify UUID format: alphanumeric, 20+ chars
611
+ if (/^[a-z0-9]{20,}$/i.test(query)) {
612
+ return true;
613
+ }
614
+ // Standard UUID format with hyphens (8-4-4-4-12)
615
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(query)) {
616
+ return true;
617
+ }
618
+ return false;
619
+ }
620
+ /**
621
+ * Find an application by UUID, name, or domain (FQDN).
622
+ * Returns the UUID if found, throws if not found or multiple matches.
623
+ */
624
+ async resolveApplicationUuid(query) {
625
+ // If it looks like a UUID, use it directly
626
+ if (this.isLikelyUuid(query)) {
627
+ return query;
628
+ }
629
+ // Otherwise, search by name or domain
630
+ const apps = (await this.listApplications());
631
+ const queryLower = query.toLowerCase();
632
+ const matches = apps.filter((app) => {
633
+ const nameMatch = app.name?.toLowerCase().includes(queryLower);
634
+ const fqdnMatch = app.fqdn?.toLowerCase().includes(queryLower);
635
+ return nameMatch || fqdnMatch;
636
+ });
637
+ if (matches.length === 0) {
638
+ throw new Error(`No application found matching "${query}"`);
639
+ }
640
+ if (matches.length > 1) {
641
+ const matchList = matches.map((a) => `${a.name} (${a.fqdn || 'no domain'})`).join(', ');
642
+ throw new Error(`Multiple applications match "${query}": ${matchList}. Please be more specific or use a UUID.`);
643
+ }
644
+ return matches[0].uuid;
645
+ }
646
+ /**
647
+ * Find a server by UUID, name, or IP address.
648
+ * Returns the UUID if found, throws if not found or multiple matches.
649
+ */
650
+ async resolveServerUuid(query) {
651
+ // If it looks like a UUID, use it directly
652
+ if (this.isLikelyUuid(query)) {
653
+ return query;
654
+ }
655
+ // Otherwise, search by name or IP
656
+ const servers = (await this.listServers());
657
+ const queryLower = query.toLowerCase();
658
+ const matches = servers.filter((server) => {
659
+ const nameMatch = server.name?.toLowerCase().includes(queryLower);
660
+ const ipMatch = server.ip?.toLowerCase().includes(queryLower);
661
+ return nameMatch || ipMatch;
662
+ });
663
+ if (matches.length === 0) {
664
+ throw new Error(`No server found matching "${query}"`);
665
+ }
666
+ if (matches.length > 1) {
667
+ const matchList = matches.map((s) => `${s.name} (${s.ip})`).join(', ');
668
+ throw new Error(`Multiple servers match "${query}": ${matchList}. Please be more specific or use a UUID.`);
669
+ }
670
+ return matches[0].uuid;
671
+ }
672
+ // ===========================================================================
673
+ // Diagnostic endpoints (composite tools)
674
+ // ===========================================================================
675
+ /**
676
+ * Get comprehensive diagnostic info for an application.
677
+ * Aggregates: application details, logs, env vars, recent deployments.
678
+ * @param query - Application UUID, name, or domain (FQDN)
679
+ */
680
+ async diagnoseApplication(query) {
681
+ // Resolve query to UUID
682
+ let uuid;
683
+ try {
684
+ uuid = await this.resolveApplicationUuid(query);
685
+ }
686
+ catch (error) {
687
+ const msg = error instanceof Error ? error.message : String(error);
688
+ return {
689
+ application: null,
690
+ health: { status: 'unknown', issues: [] },
691
+ logs: null,
692
+ environment_variables: { count: 0, variables: [] },
693
+ recent_deployments: [],
694
+ errors: [msg],
695
+ };
696
+ }
697
+ const results = await Promise.allSettled([
698
+ this.getApplication(uuid),
699
+ this.getApplicationLogs(uuid, 50),
700
+ this.listApplicationEnvVars(uuid),
701
+ this.listApplicationDeployments(uuid),
702
+ ]);
703
+ const errors = [];
704
+ const extract = (result, name) => {
705
+ if (result.status === 'fulfilled')
706
+ return result.value;
707
+ const msg = result.reason instanceof Error ? result.reason.message : String(result.reason);
708
+ errors.push(`${name}: ${msg}`);
709
+ return null;
710
+ };
711
+ const app = extract(results[0], 'application');
712
+ const logs = extract(results[1], 'logs');
713
+ const envVars = extract(results[2], 'environment_variables');
714
+ const deployments = extract(results[3], 'deployments');
715
+ // Determine health status and issues
716
+ const issues = [];
717
+ let healthStatus = 'unknown';
718
+ if (app) {
719
+ const status = app.status || '';
720
+ if (status.includes('running') && status.includes('healthy')) {
721
+ healthStatus = 'healthy';
722
+ }
723
+ else if (status.includes('exited') ||
724
+ status.includes('unhealthy') ||
725
+ status.includes('error')) {
726
+ healthStatus = 'unhealthy';
727
+ issues.push(`Status: ${status}`);
728
+ }
729
+ else if (status.includes('running')) {
730
+ healthStatus = 'healthy';
731
+ }
732
+ else {
733
+ issues.push(`Status: ${status}`);
734
+ }
735
+ }
736
+ // Check for failed deployments
737
+ if (deployments) {
738
+ const recentFailed = deployments.slice(0, 5).filter((d) => d.status === 'failed');
739
+ if (recentFailed.length > 0) {
740
+ issues.push(`${recentFailed.length} failed deployment(s) in last 5`);
741
+ if (healthStatus === 'healthy')
742
+ healthStatus = 'unhealthy';
743
+ }
744
+ }
745
+ return {
746
+ application: app
747
+ ? {
748
+ uuid: app.uuid,
749
+ name: app.name,
750
+ status: app.status || 'unknown',
751
+ fqdn: app.fqdn || null,
752
+ git_repository: app.git_repository || null,
753
+ git_branch: app.git_branch || null,
754
+ }
755
+ : null,
756
+ health: {
757
+ status: healthStatus,
758
+ issues,
759
+ },
760
+ logs: typeof logs === 'string' ? logs : null,
761
+ environment_variables: {
762
+ count: envVars?.length || 0,
763
+ variables: (envVars || []).map((v) => ({
764
+ key: v.key,
765
+ is_build_time: v.is_build_time ?? false,
766
+ })),
767
+ },
768
+ recent_deployments: (deployments || []).slice(0, 5).map((d) => ({
769
+ uuid: d.uuid,
770
+ status: d.status,
771
+ created_at: d.created_at,
772
+ })),
773
+ ...(errors.length > 0 && { errors }),
774
+ };
775
+ }
776
+ /**
777
+ * Get comprehensive diagnostic info for a server.
778
+ * Aggregates: server details, resources, domains, validation.
779
+ * @param query - Server UUID, name, or IP address
780
+ */
781
+ async diagnoseServer(query) {
782
+ // Resolve query to UUID
783
+ let uuid;
784
+ try {
785
+ uuid = await this.resolveServerUuid(query);
786
+ }
787
+ catch (error) {
788
+ const msg = error instanceof Error ? error.message : String(error);
789
+ return {
790
+ server: null,
791
+ health: { status: 'unknown', issues: [] },
792
+ resources: [],
793
+ domains: [],
794
+ validation: null,
795
+ errors: [msg],
796
+ };
797
+ }
798
+ const results = await Promise.allSettled([
799
+ this.getServer(uuid),
800
+ this.getServerResources(uuid),
801
+ this.getServerDomains(uuid),
802
+ this.validateServer(uuid),
803
+ ]);
804
+ const errors = [];
805
+ const extract = (result, name) => {
806
+ if (result.status === 'fulfilled')
807
+ return result.value;
808
+ const msg = result.reason instanceof Error ? result.reason.message : String(result.reason);
809
+ errors.push(`${name}: ${msg}`);
810
+ return null;
811
+ };
812
+ const server = extract(results[0], 'server');
813
+ const resources = extract(results[1], 'resources');
814
+ const domains = extract(results[2], 'domains');
815
+ const validation = extract(results[3], 'validation');
816
+ // Determine health status and issues
817
+ const issues = [];
818
+ let healthStatus = 'unknown';
819
+ if (server) {
820
+ if (server.is_reachable === true) {
821
+ healthStatus = 'healthy';
822
+ }
823
+ else if (server.is_reachable === false) {
824
+ healthStatus = 'unhealthy';
825
+ issues.push('Server is not reachable');
826
+ }
827
+ if (server.is_usable === false) {
828
+ issues.push('Server is not usable');
829
+ healthStatus = 'unhealthy';
830
+ }
831
+ }
832
+ // Check for unhealthy resources
833
+ if (resources) {
834
+ const unhealthyResources = resources.filter((r) => r.status.includes('exited') ||
835
+ r.status.includes('unhealthy') ||
836
+ r.status.includes('error'));
837
+ if (unhealthyResources.length > 0) {
838
+ issues.push(`${unhealthyResources.length} unhealthy resource(s)`);
839
+ }
840
+ }
841
+ return {
842
+ server: server
843
+ ? {
844
+ uuid: server.uuid,
845
+ name: server.name,
846
+ ip: server.ip,
847
+ status: server.status || null,
848
+ is_reachable: server.is_reachable ?? null,
849
+ }
850
+ : null,
851
+ health: {
852
+ status: healthStatus,
853
+ issues,
854
+ },
855
+ resources: (resources || []).map((r) => ({
856
+ uuid: r.uuid,
857
+ name: r.name,
858
+ type: r.type,
859
+ status: r.status,
860
+ })),
861
+ domains: (domains || []).map((d) => ({
862
+ ip: d.ip,
863
+ domains: d.domains,
864
+ })),
865
+ validation: validation
866
+ ? {
867
+ message: validation.message,
868
+ ...(validation.validation_logs && { validation_logs: validation.validation_logs }),
869
+ }
870
+ : null,
871
+ ...(errors.length > 0 && { errors }),
872
+ };
873
+ }
874
+ /**
875
+ * Scan infrastructure for common issues.
876
+ * Finds: unreachable servers, unhealthy apps, exited databases, stopped services.
877
+ */
878
+ async findInfrastructureIssues() {
879
+ const results = await Promise.allSettled([
880
+ this.listServers(),
881
+ this.listApplications(),
882
+ this.listDatabases(),
883
+ this.listServices(),
884
+ ]);
885
+ const errors = [];
886
+ const issues = [];
887
+ const extract = (result, name) => {
888
+ if (result.status === 'fulfilled')
889
+ return result.value;
890
+ const msg = result.reason instanceof Error ? result.reason.message : String(result.reason);
891
+ errors.push(`${name}: ${msg}`);
892
+ return null;
893
+ };
894
+ const servers = extract(results[0], 'servers');
895
+ const applications = extract(results[1], 'applications');
896
+ const databases = extract(results[2], 'databases');
897
+ const services = extract(results[3], 'services');
898
+ // Check servers for unreachable
899
+ if (servers) {
900
+ for (const server of servers) {
901
+ if (server.is_reachable === false) {
902
+ issues.push({
903
+ type: 'server',
904
+ uuid: server.uuid,
905
+ name: server.name,
906
+ issue: 'Server is not reachable',
907
+ status: server.status || 'unreachable',
908
+ });
909
+ }
910
+ }
911
+ }
912
+ // Check applications for unhealthy status
913
+ if (applications) {
914
+ for (const app of applications) {
915
+ const status = app.status || '';
916
+ if (status.includes('exited') ||
917
+ status.includes('unhealthy') ||
918
+ status.includes('error') ||
919
+ status === 'stopped') {
920
+ issues.push({
921
+ type: 'application',
922
+ uuid: app.uuid,
923
+ name: app.name,
924
+ issue: `Application status: ${status}`,
925
+ status,
926
+ });
927
+ }
928
+ }
929
+ }
930
+ // Check databases for unhealthy status
931
+ if (databases) {
932
+ for (const db of databases) {
933
+ const status = db.status || '';
934
+ if (status.includes('exited') ||
935
+ status.includes('unhealthy') ||
936
+ status.includes('error') ||
937
+ status === 'stopped') {
938
+ issues.push({
939
+ type: 'database',
940
+ uuid: db.uuid,
941
+ name: db.name,
942
+ issue: `Database status: ${status}`,
943
+ status,
944
+ });
945
+ }
946
+ }
947
+ }
948
+ // Check services for unhealthy status
949
+ if (services) {
950
+ for (const svc of services) {
951
+ const status = svc.status || '';
952
+ if (status.includes('exited') ||
953
+ status.includes('unhealthy') ||
954
+ status.includes('error') ||
955
+ status === 'stopped') {
956
+ issues.push({
957
+ type: 'service',
958
+ uuid: svc.uuid,
959
+ name: svc.name,
960
+ issue: `Service status: ${status}`,
961
+ status,
962
+ });
963
+ }
964
+ }
965
+ }
966
+ return {
967
+ summary: {
968
+ total_issues: issues.length,
969
+ unhealthy_applications: issues.filter((i) => i.type === 'application').length,
970
+ unhealthy_databases: issues.filter((i) => i.type === 'database').length,
971
+ unhealthy_services: issues.filter((i) => i.type === 'service').length,
972
+ unreachable_servers: issues.filter((i) => i.type === 'server').length,
973
+ },
974
+ issues,
975
+ ...(errors.length > 0 && { errors }),
976
+ };
977
+ }
601
978
  }
@@ -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.0';
24
24
  /** Wrap tool handler with consistent error handling */
25
25
  function wrapHandler(fn) {
26
26
  return fn()
@@ -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., "tidylinker.com" 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
  }
@@ -653,3 +653,78 @@ export interface HealthCheck {
653
653
  status: 'healthy' | 'unhealthy';
654
654
  version?: string;
655
655
  }
656
+ export type DiagnosticHealthStatus = 'healthy' | 'unhealthy' | 'unknown';
657
+ export interface ApplicationDiagnostic {
658
+ application: {
659
+ uuid: string;
660
+ name: string;
661
+ status: string;
662
+ fqdn: string | null;
663
+ git_repository: string | null;
664
+ git_branch: string | null;
665
+ } | null;
666
+ health: {
667
+ status: DiagnosticHealthStatus;
668
+ issues: string[];
669
+ };
670
+ logs: string | null;
671
+ environment_variables: {
672
+ count: number;
673
+ variables: Array<{
674
+ key: string;
675
+ is_build_time: boolean;
676
+ }>;
677
+ };
678
+ recent_deployments: Array<{
679
+ uuid: string;
680
+ status: string;
681
+ created_at: string;
682
+ }>;
683
+ errors?: string[];
684
+ }
685
+ export interface ServerDiagnostic {
686
+ server: {
687
+ uuid: string;
688
+ name: string;
689
+ ip: string;
690
+ status: string | null;
691
+ is_reachable: boolean | null;
692
+ } | null;
693
+ health: {
694
+ status: DiagnosticHealthStatus;
695
+ issues: string[];
696
+ };
697
+ resources: Array<{
698
+ uuid: string;
699
+ name: string;
700
+ type: string;
701
+ status: string;
702
+ }>;
703
+ domains: Array<{
704
+ ip: string;
705
+ domains: string[];
706
+ }>;
707
+ validation: {
708
+ message: string;
709
+ validation_logs?: string;
710
+ } | null;
711
+ errors?: string[];
712
+ }
713
+ export interface InfrastructureIssue {
714
+ type: 'application' | 'database' | 'service' | 'server';
715
+ uuid: string;
716
+ name: string;
717
+ issue: string;
718
+ status: string;
719
+ }
720
+ export interface InfrastructureIssuesReport {
721
+ summary: {
722
+ total_issues: number;
723
+ unhealthy_applications: number;
724
+ unhealthy_databases: number;
725
+ unhealthy_services: number;
726
+ unreachable_servers: number;
727
+ };
728
+ issues: InfrastructureIssue[];
729
+ errors?: string[];
730
+ }
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.0",
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",