@@ -30,6 +30,7 @@ import (
3030 "time"
3131
3232 "github.com/cronitorio/cronitor-cli/lib"
33+ "github.com/mark3labs/mcp-go/server"
3334 "github.com/spf13/cobra"
3435 "github.com/spf13/viper"
3536 "golang.org/x/time/rate"
@@ -591,12 +592,56 @@ func openBrowser(url string) {
591592 }
592593}
593594
595+ // Add new configuration variables
596+ const (
597+ varMCPEnabled = "CRONITOR_MCP_ENABLED"
598+ varMCPMode = "CRONITOR_MCP_MODE"
599+ varMCPInstance = "CRONITOR_MCP_INSTANCE"
600+ )
601+
594602var dashCmd = & cobra.Command {
595603 Use : "dash" ,
596604 Short : "Start the web dashboard" ,
597605 Long : `Start the Crontab Guru web dashboard.
598- The dashboard provides a web interface for managing your cron jobs and crontab files.` ,
606+ The dashboard provides a web interface for managing your cron jobs and crontab files.
607+
608+ MCP Mode:
609+ When --mcp-instance flag is used, the dashboard runs in MCP (Model Context Protocol) mode
610+ for integration with Cursor IDE or other LLM applications. In this mode, it does
611+ not start a web server but instead communicates via stdio protocol.` ,
599612 Run : func (cmd * cobra.Command , args []string ) {
613+ // Get MCP instance name
614+ mcpInstance , _ := cmd .Flags ().GetString ("mcp-instance" )
615+
616+ // Check if MCP mode is enabled
617+ mcpEnabled := false
618+
619+ // If mcp-instance is provided, automatically enable MCP mode
620+ if mcpInstance != "" {
621+ mcpEnabled = true
622+ } else {
623+ // Check explicit --mcp flag for backward compatibility
624+ mcpEnabled , _ = cmd .Flags ().GetBool ("mcp" )
625+ if ! mcpEnabled {
626+ // Check environment variable
627+ mcpEnabled = viper .GetBool (varMCPEnabled )
628+ }
629+
630+ // If MCP is enabled but no instance specified, use default
631+ if mcpEnabled && mcpInstance == "" {
632+ mcpInstance = viper .GetString (varMCPInstance )
633+ if mcpInstance == "" {
634+ mcpInstance = "default"
635+ }
636+ }
637+ }
638+
639+ // If MCP mode is enabled, run MCP server instead of web dashboard
640+ if mcpEnabled {
641+ runMCPServer (mcpInstance )
642+ return
643+ }
644+
600645 // Check for dashboard credentials before starting the server
601646 username := viper .GetString (varDashUsername )
602647 password := viper .GetString (varDashPassword )
@@ -938,6 +983,34 @@ The dashboard provides a web interface for managing your cron jobs and crontab f
938983 },
939984}
940985
986+ // runMCPServer starts the MCP server for Cursor integration
987+ func runMCPServer (instanceName string ) {
988+ // Create MCP server
989+ s := server .NewMCPServer (
990+ fmt .Sprintf ("Cronitor Dashboard (%s)" , instanceName ),
991+ Version ,
992+ server .WithToolCapabilities (false ),
993+ )
994+
995+ // Create and register the Cronitor MCP handler
996+ handler := lib .NewCronitorMCPHandler (instanceName )
997+
998+ // Register tools
999+ if err := handler .RegisterTools (s ); err != nil {
1000+ fatal (fmt .Sprintf ("Failed to register MCP tools: %v" , err ), 1 )
1001+ }
1002+
1003+ // Register resources
1004+ if err := handler .RegisterResources (s ); err != nil {
1005+ fatal (fmt .Sprintf ("Failed to register MCP resources: %v" , err ), 1 )
1006+ }
1007+
1008+ // Start the stdio server
1009+ if err := server .ServeStdio (s ); err != nil {
1010+ fatal (fmt .Sprintf ("MCP server error: %v" , err ), 1 )
1011+ }
1012+ }
1013+
9411014// Helper function to slugify a string for filenames
9421015func slugify (s string ) string {
9431016 // Convert to lowercase
@@ -980,22 +1053,30 @@ func init() {
9801053 RootCmd .AddCommand (dashCmd )
9811054 dashCmd .Flags ().Int ("port" , 9000 , "Port to run the dashboard on" )
9821055 dashCmd .Flags ().Bool ("safe-mode" , false , "Limit the ability to edit jobs, crontabs, and settings" )
1056+ dashCmd .Flags ().Bool ("mcp" , false , "Enable MCP server mode for Cursor integration (deprecated: use --mcp-instance instead)" )
1057+ dashCmd .Flags ().String ("mcp-instance" , "" , "MCP instance name to connect to (automatically enables MCP mode)" )
1058+
1059+ // Bind MCP flags to viper
1060+ viper .BindPFlag (varMCPEnabled , dashCmd .Flags ().Lookup ("mcp" ))
1061+ viper .BindPFlag (varMCPInstance , dashCmd .Flags ().Lookup ("mcp-instance" ))
9831062}
9841063
9851064type SettingsResponse struct {
9861065 ConfigFile
987- EnvVars map [string ]bool `json:"env_vars"`
988- ConfigFilePath string `json:"config_file_path"`
989- Version string `json:"version"`
990- Hostname string `json:"hostname"`
991- Timezone string `json:"timezone"`
992- Timezones []string `json:"timezones"`
993- OS string `json:"os"`
994- SafeMode bool `json:"safe_mode"`
995- UpdateStatus * UpdateStatus `json:"update_status,omitempty"`
996- AllowedIPs string `json:"allowed_ips"`
997- CorsAllowedOrigins string `json:"cors_allowed_origins"`
998- ClientIP string `json:"client_ip"`
1066+ EnvVars map [string ]bool `json:"env_vars"`
1067+ ConfigFilePath string `json:"config_file_path"`
1068+ Version string `json:"version"`
1069+ Hostname string `json:"hostname"`
1070+ Timezone string `json:"timezone"`
1071+ Timezones []string `json:"timezones"`
1072+ OS string `json:"os"`
1073+ SafeMode bool `json:"safe_mode"`
1074+ UpdateStatus * UpdateStatus `json:"update_status,omitempty"`
1075+ AllowedIPs string `json:"allowed_ips"`
1076+ CorsAllowedOrigins string `json:"cors_allowed_origins"`
1077+ ClientIP string `json:"client_ip"`
1078+ MCPEnabled bool `json:"mcp_enabled"`
1079+ MCPInstances map [string ]MCPInstanceConfig `json:"mcp_instances,omitempty"`
9991080}
10001081
10011082// handleSettings handles GET and POST requests for settings
@@ -1120,13 +1201,16 @@ func handleSettings(w http.ResponseWriter, r *http.Request) {
11201201 "CRONITOR_ALLOWED_IPS" : os .Getenv (varAllowedIPs ) != "" ,
11211202 "CRONITOR_CORS_ALLOWED_ORIGINS" : os .Getenv ("CRONITOR_CORS_ALLOWED_ORIGINS" ) != "" ,
11221203 "CRONITOR_USERS" : os .Getenv (varUsers ) != "" ,
1204+ "CRONITOR_MCP_ENABLED" : os .Getenv (varMCPEnabled ) != "" ,
11231205 },
11241206 OS : runtime .GOOS ,
11251207 SafeMode : isSafeModeEnabled ,
11261208 UpdateStatus : updateStatus ,
11271209 AllowedIPs : os .Getenv (varAllowedIPs ),
11281210 CorsAllowedOrigins : os .Getenv ("CRONITOR_CORS_ALLOWED_ORIGINS" ),
11291211 ClientIP : getClientIP (r ),
1212+ MCPEnabled : configData .MCPEnabled ,
1213+ MCPInstances : configData .MCPInstances ,
11301214 }
11311215
11321216 // Override config values with environment variables if they exist
@@ -1205,6 +1289,16 @@ func handleSettings(w http.ResponseWriter, r *http.Request) {
12051289 }
12061290 }
12071291
1292+ // Handle MCP settings
1293+ if os .Getenv (varMCPEnabled ) == "" {
1294+ viper .Set (varMCPEnabled , configData .MCPEnabled )
1295+ }
1296+
1297+ // Always update MCP instances configuration
1298+ if configData .MCPInstances != nil {
1299+ viper .Set ("mcp_instances" , configData .MCPInstances )
1300+ }
1301+
12081302 // Marshal the config data
12091303 b , err := json .MarshalIndent (configData , "" , " " )
12101304 if err != nil {
@@ -2788,6 +2882,114 @@ func performUpdateWithRestart(status *UpdateStatus, sendProgress func(string)) e
27882882 // Give the UI a moment to receive the message
27892883 time .Sleep (1 * time .Second )
27902884
2885+ // Check if we're running under systemd
2886+ if isRunningUnderSystemd () {
2887+ sendProgress ("Detected systemd service, requesting service restart..." )
2888+
2889+ // When running under systemd, we should let systemd handle the restart
2890+ // Signal systemd to restart the service
2891+ if err := requestSystemdRestart (); err != nil {
2892+ // If systemd restart fails, fall back to manual restart
2893+ log (fmt .Sprintf ("Failed to request systemd restart, falling back to manual restart: %v" , err ))
2894+ return performManualRestart (sendProgress )
2895+ }
2896+
2897+ sendProgress ("Update complete! Service restarting via systemd..." )
2898+
2899+ // Give a brief moment for the response to be sent, then exit gracefully
2900+ go func () {
2901+ time .Sleep (500 * time .Millisecond )
2902+ os .Exit (0 )
2903+ }()
2904+
2905+ return nil
2906+ }
2907+
2908+ // For non-systemd environments, use manual restart
2909+ return performManualRestart (sendProgress )
2910+ }
2911+
2912+ // isRunningUnderSystemd checks if the current process is running under systemd
2913+ func isRunningUnderSystemd () bool {
2914+ // Check if NOTIFY_SOCKET environment variable is set (systemd sets this)
2915+ if os .Getenv ("NOTIFY_SOCKET" ) != "" {
2916+ return true
2917+ }
2918+
2919+ // Check if we're running as PID 1's direct child and systemd is PID 1
2920+ ppid := os .Getppid ()
2921+ if ppid == 1 {
2922+ // Check if PID 1 is systemd
2923+ if cmdline , err := os .ReadFile ("/proc/1/cmdline" ); err == nil {
2924+ return strings .Contains (string (cmdline ), "systemd" )
2925+ }
2926+ }
2927+
2928+ // Check if systemd journal is available
2929+ if _ , err := os .Stat ("/run/systemd/journal" ); err == nil {
2930+ // Also check if we have systemd process manager
2931+ if _ , err := os .Stat ("/run/systemd/system" ); err == nil {
2932+ return true
2933+ }
2934+ }
2935+
2936+ return false
2937+ }
2938+
2939+ // requestSystemdRestart attempts to restart the service via systemd
2940+ func requestSystemdRestart () error {
2941+ // Try to determine the service name by looking at the process
2942+ serviceName := getSystemdServiceName ()
2943+ if serviceName == "" {
2944+ return fmt .Errorf ("could not determine systemd service name" )
2945+ }
2946+
2947+ // Use systemctl to restart the service
2948+ cmd := exec .Command ("systemctl" , "restart" , serviceName )
2949+ if err := cmd .Run (); err != nil {
2950+ return fmt .Errorf ("failed to restart systemd service %s: %v" , serviceName , err )
2951+ }
2952+
2953+ return nil
2954+ }
2955+
2956+ // getSystemdServiceName attempts to determine the systemd service name
2957+ func getSystemdServiceName () string {
2958+ // Try to get service name from systemd environment
2959+ if serviceName := os .Getenv ("SYSTEMD_SERVICE" ); serviceName != "" {
2960+ return serviceName
2961+ }
2962+
2963+ // Try common service names based on executable name
2964+ executable , err := os .Executable ()
2965+ if err != nil {
2966+ return ""
2967+ }
2968+
2969+ baseName := strings .TrimSuffix (filepath .Base (executable ), filepath .Ext (executable ))
2970+
2971+ // Common patterns for systemd service names
2972+ possibleNames := []string {
2973+ baseName + ".service" ,
2974+ baseName + "-dashboard.service" ,
2975+ "crontab-guru-dashboard.service" , // User mentioned this specific name
2976+ "cronitor-cli.service" ,
2977+ "cronitor.service" ,
2978+ }
2979+
2980+ // Check which service exists and is active
2981+ for _ , name := range possibleNames {
2982+ cmd := exec .Command ("systemctl" , "is-active" , "--quiet" , name )
2983+ if err := cmd .Run (); err == nil {
2984+ return name
2985+ }
2986+ }
2987+
2988+ return ""
2989+ }
2990+
2991+ // performManualRestart performs a manual restart (non-systemd)
2992+ func performManualRestart (sendProgress func (string )) error {
27912993 // Get current executable path and arguments
27922994 executable , err := os .Executable ()
27932995 if err != nil {
0 commit comments