diff --git a/README.md b/README.md index eb9acec..f6d01d2 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Run MATLAB® using AI applications. The MATLAB MCP Core Server allows your AI ap - [Setup](#setup) - [Claude Code](#claude-code) - [Claude Desktop](#claude-desktop) + - [Cursor](#cursor) - [GitHub Copilot in Visual Studio Code](#github-copilot-in-visual-studio-code) - [Arguments](#arguments) - [Tools](#tools) @@ -20,7 +21,7 @@ Run MATLAB® using AI applications. The MATLAB MCP Core Server allows your AI ap 1. Install [MATLAB (MathWorks)](https://www.mathworks.com/help/install/ug/install-products-with-internet-connection.html) 2020b or later and add it to the system PATH. 2. Download the [Latest Release](https://github.com/matlab/matlab-mcp-core-server/releases/latest) from GitHub®. Alternatively, you can install [Go](https://go.dev/doc/install) and build the binary from source using `go install github.com/matlab/matlab-mcp-core-server/cmd/matlab-mcp-core-server`. -3. Add the MATLAB MCP Core Server to your AI application. You can find instructions for adding MCP servers in the documentation of your AI application. For example instructions on using Claude Code®, Claude Desktop®, and GitHub Copilot in Visual Studio® Code, see below. Note that you can customize the server by specifying optional [arguments](#arguments). +3. Add the MATLAB MCP Core Server to your AI application. You can find instructions for adding MCP servers in the documentation of your AI application. For example instructions on using Claude Code®, Claude Desktop®, Cursor, and GitHub Copilot in Visual Studio® Code, see below. Note that you can customize the server by specifying optional [arguments](#arguments). #### Download on macOS @@ -86,6 +87,27 @@ Follow the instructions on the page [Connect to local MCP servers (MCP)](https:/ ``` After saving the configuration file, quit and restart Claude Desktop. +### Cursor + +Configure the MATLAB MCP Core Server in Cursor by creating an `mcp.json` file. You can create a project-specific configuration in `.cursor/mcp.json` within your project directory, or a global configuration in `~/.cursor/mcp.json` in your home directory. In the configuration file, add the following, remembering to insert the full path to the server binary you acquired in the setup, as well as any other arguments: + +```json +{ + "mcpServers": { + "matlab": { + "command": "fullpath/to/matlab-mcp-core-server-binary", + "args": [ + "--initial-working-folder=/home/username/Documents" + ] + } + } +} +``` + +After saving the configuration file, restart Cursor to apply the changes. + +For details on adding MCP servers in Cursor, see [Model Context Protocol (Cursor)](https://cursor.com/docs/context/mcp#model-context-protocol). + ### GitHub Copilot in Visual Studio Code VS Code provides different methods to [Add an MCP Server (VS Code)](https://code.visualstudio.com/docs/copilot/customization/mcp-servers?wt.md_id=AZ-MVP-5004796#_add-an-mcp-server). MathWorks recommends you follow the steps in the section **"Add an MCP server to a workspace `mcp.json` file"**. In your `mcp.json` configuration file, add the following, remembering to insert the full path to the server binary you acquired in the setup, as well as any arguments: diff --git a/cmd/matlab-mcp-core-server/main.go b/cmd/matlab-mcp-core-server/main.go index 9487a9a..f89dfa7 100644 --- a/cmd/matlab-mcp-core-server/main.go +++ b/cmd/matlab-mcp-core-server/main.go @@ -4,13 +4,43 @@ package main import ( "context" + "fmt" "log/slog" "os" + "github.com/matlab/matlab-mcp-core-server/internal/utils/instancelock" "github.com/matlab/matlab-mcp-core-server/internal/wire" ) func main() { + // Check for existing instance before doing anything else + instanceLock, err := instancelock.New() + if err != nil { + slog.With("error", err).Error("Failed to create instance lock.") + os.Exit(1) + } + + // Try to acquire lock, killing existing instance if found + // This ensures a fresh start when Cursor restarts the MCP server + acquired, err := instanceLock.TryLockWithKill(true) + if err != nil { + slog.With("error", err).Error("Failed to acquire instance lock.") + os.Exit(1) + } + + if !acquired { + // This shouldn't happen if killExisting is true, but handle it anyway + fmt.Fprintf(os.Stderr, "MATLAB MCP Core Server is already running. Only one instance is allowed.\n") + os.Exit(0) + } + + // Ensure lock is released on exit + defer func() { + if err := instanceLock.Unlock(); err != nil { + slog.With("error", err).Warn("Failed to release instance lock on exit.") + } + }() + modeSelector, err := wire.InitializeModeSelector() if err != nil { // As we failed to even initialize, we cannot use a LoggerFactory, diff --git a/internal/adaptors/globalmatlab/globalmatlab.go b/internal/adaptors/globalmatlab/globalmatlab.go index d4f5a2e..88bbc1c 100644 --- a/internal/adaptors/globalmatlab/globalmatlab.go +++ b/internal/adaptors/globalmatlab/globalmatlab.go @@ -4,7 +4,10 @@ package globalmatlab import ( "context" + "fmt" + "os" "sync" + "time" "github.com/matlab/matlab-mcp-core-server/internal/entities" ) @@ -32,6 +35,7 @@ type GlobalMATLAB struct { matlabStartingDir string sessionID entities.SessionID cachedStartErr error + isReady bool } func New( @@ -49,6 +53,9 @@ func New( } func (g *GlobalMATLAB) Initialize(ctx context.Context, logger entities.Logger) error { + logger = logger.With("mcp_server_pid", os.Getpid()) + logger.Debug("GlobalMATLAB.Initialize called") + var err error g.matlabRoot, err = g.matlabRootSelector.SelectFirstMATLABVersionOnPath(ctx, logger) if err != nil { @@ -65,20 +72,60 @@ func (g *GlobalMATLAB) Initialize(ctx context.Context, logger entities.Logger) e return err } + logger.Debug("GlobalMATLAB.Initialize completed successfully") return nil } func (g *GlobalMATLAB) Client(ctx context.Context, logger entities.Logger) (entities.MATLABSessionClient, error) { - if err := g.ensureMATLABClientIsValid(ctx, logger); err != nil { - return nil, err - } + // Retry logic with exponential backoff for transient connection failures + var lastErr error + maxRetries := 5 + baseDelay := 200 * time.Millisecond + + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + // Exponential backoff: 200ms, 400ms, 800ms, 1600ms, 3200ms + delay := baseDelay * time.Duration(1< 0 { + delay := baseDelay * time.Duration(1< 5*time.Second { + delay = 5 * time.Second // Cap at 5 seconds + } + logger.With("attempt", attempt+1).With("delay_ms", delay.Milliseconds()).Debug("Waiting for MATLAB to be ready") + + select { + case <-readyCtx.Done(): + return fmt.Errorf("timeout waiting for MATLAB to be ready: %w", readyCtx.Err()) + case <-time.After(delay): + } + } + + // Test connection with a simple eval + _, err := client.Eval(readyCtx, logger, entities.EvalRequest{ + Code: "1+1", + }) + + if err == nil { + logger.Debug("MATLAB connection test successful") + return nil + } + + logger.WithError(err).With("attempt", attempt+1).Debug("MATLAB connection test failed, will retry") + } + + return fmt.Errorf("MATLAB connection not ready after %d attempts", maxAttempts) +} diff --git a/internal/adaptors/matlabmanager/matlabservices/services/localmatlabsession/localmatlabsession.go b/internal/adaptors/matlabmanager/matlabservices/services/localmatlabsession/localmatlabsession.go index e7f8ec6..5d29482 100644 --- a/internal/adaptors/matlabmanager/matlabservices/services/localmatlabsession/localmatlabsession.go +++ b/internal/adaptors/matlabmanager/matlabservices/services/localmatlabsession/localmatlabsession.go @@ -3,6 +3,7 @@ package localmatlabsession import ( + "os" "runtime" "github.com/matlab/matlab-mcp-core-server/internal/adaptors/matlabmanager/matlabservices/datatypes" @@ -51,7 +52,8 @@ func NewStarter( } func (m *Starter) StartLocalMATLABSession(logger entities.Logger, request datatypes.LocalSessionDetails) (embeddedconnector.ConnectionDetails, func() error, error) { - logger.Debug("Starting a local MATLAB session") + logger = logger.With("mcp_server_pid", os.Getpid()) + logger.With("matlab_root", request.MATLABRoot).With("show_desktop", request.ShowMATLABDesktop).Debug("StartLocalMATLABSession called") sessionDir, err := m.directoryFactory.Create(logger) if err != nil { @@ -79,12 +81,16 @@ func (m *Starter) StartLocalMATLABSession(logger entities.Logger, request dataty startupCode := "sessionPath = '" + sessionDirPath + "';addpath(sessionPath);matlab_mcp.initializeMCP();clear sessionPath;" startupFlags := m.processDetails.StartupFlag(runtime.GOOS, request.ShowMATLABDesktop, startupCode) + logger.With("flags", startupFlags).Debug("MATLAB startup flags") processID, processCleanup, err := m.matlabProcessLauncher.Launch(logger, sessionDirPath, request.MATLABRoot, request.StartingDirectory, startupFlags, env) if err != nil { + logger.WithError(err).Error("Failed to launch MATLAB process") return embeddedconnector.ConnectionDetails{}, nil, err } + logger.With("matlab_process_id", processID).Debug("MATLAB process launched") + if err = m.watchdog.RegisterProcessPIDWithWatchdog(processID); err != nil { logger.WithError(err).Warn("Failed to register process with watchdog") } diff --git a/internal/adaptors/matlabmanager/matlabservices/services/localmatlabsession/processdetails/processdetails.go b/internal/adaptors/matlabmanager/matlabservices/services/localmatlabsession/processdetails/processdetails.go index 9605dfd..04478f2 100644 --- a/internal/adaptors/matlabmanager/matlabservices/services/localmatlabsession/processdetails/processdetails.go +++ b/internal/adaptors/matlabmanager/matlabservices/services/localmatlabsession/processdetails/processdetails.go @@ -94,6 +94,12 @@ func (*ProcessDetails) StartupFlag(os string, showMATLAB bool, startupCode strin "-noDisplayDesktop", "-wait", "-log", + "/minimize", + ) + } else { + // Unix platforms (Linux/macOS) + startupFlags = append(startupFlags, + "-minimize", ) } } diff --git a/internal/adaptors/matlabmanager/matlabservices/services/localmatlabsession/processdetails/processdetails_test.go b/internal/adaptors/matlabmanager/matlabservices/services/localmatlabsession/processdetails/processdetails_test.go index 9c3d113..6104c0d 100644 --- a/internal/adaptors/matlabmanager/matlabservices/services/localmatlabsession/processdetails/processdetails_test.go +++ b/internal/adaptors/matlabmanager/matlabservices/services/localmatlabsession/processdetails/processdetails_test.go @@ -190,6 +190,7 @@ func TestProcessDetails_StartupFlag_HappyPath(t *testing.T) { "-nosplash", "-softwareopengl", "-nodesktop", + "-minimize", }, }, { @@ -209,6 +210,7 @@ func TestProcessDetails_StartupFlag_HappyPath(t *testing.T) { "-noDisplayDesktop", "-wait", "-log", + "/minimize", }, }, { @@ -225,6 +227,7 @@ func TestProcessDetails_StartupFlag_HappyPath(t *testing.T) { "-nosplash", "-softwareopengl", "-nodesktop", + "-minimize", }, }, } { diff --git a/internal/adaptors/matlabmanager/matlabservices/services/localmatlabsession/processlauncher/processlauncher_windows.go b/internal/adaptors/matlabmanager/matlabservices/services/localmatlabsession/processlauncher/processlauncher_windows.go index 21e23d2..ab57f44 100644 --- a/internal/adaptors/matlabmanager/matlabservices/services/localmatlabsession/processlauncher/processlauncher_windows.go +++ b/internal/adaptors/matlabmanager/matlabservices/services/localmatlabsession/processlauncher/processlauncher_windows.go @@ -56,6 +56,11 @@ func startMatlab(logger entities.Logger, matlabRoot string, workingDir string, a si.StdOutput = windows.Handle(stdIO.stdOut.Fd()) si.StdErr = windows.Handle(stdIO.stdErr.Fd()) + // Set window to start minimized to reduce visual impact + // STARTF_USESHOWWINDOW = 0x00000001, SW_MINIMIZE = 6 + si.Flags = 0x00000001 // STARTF_USESHOWWINDOW + si.ShowWindow = 6 // SW_MINIMIZE + creationFlags := uint32(windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS | windows.CREATE_UNICODE_ENVIRONMENT) err = windows.CreateProcess( @@ -85,9 +90,14 @@ func startMatlab(logger entities.Logger, matlabRoot string, workingDir string, a return nil, fmt.Errorf("error finding MATLAB launcher process: %w", err) } - // On Windows, the process we launch is a launcher process that then launches the actual MATLAB process. - // Therefore, we need to find the child process of the launcher process. - // THere should be only one process, and that would be the actual MATLAB process. + // On Windows, MATLAB uses a launcher architecture: + // - The MCP launches matlab.exe (launcher process) + // - The launcher then spawns MATLAB.exe (actual MATLAB process) + // - This is expected Windows behavior, not a bug + // - We need to find the child process of the launcher process to track the actual MATLAB instance + // - There should be only one child process, which is the actual MATLAB process + // Note: If you see TWO FULL MATLAB instances (not just matlab.exe + MATLAB.exe), this suggests + // multiple MCP server processes are running, which would cause duplicate session initialization. matlabProcess, err := waitForMATLABProcess(logger, matlabLauncherProcess) if err != nil { return nil, err diff --git a/internal/utils/httpclientfactory/httpclientfactory.go b/internal/utils/httpclientfactory/httpclientfactory.go index 299befe..0b72e02 100644 --- a/internal/utils/httpclientfactory/httpclientfactory.go +++ b/internal/utils/httpclientfactory/httpclientfactory.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "net/http/cookiejar" + "time" ) type HttpClient interface { @@ -29,8 +30,61 @@ func (f *HTTPClientFactory) NewClientForSelfSignedTLSServer(certificatePEM []byt transport := &http.Transport{ TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: true, // We do full verification ourselves below + // Custom verification to allow clock skew tolerance + // We must use InsecureSkipVerify because Go's standard validation + // checks certificate dates BEFORE calling VerifyPeerCertificate, + // which prevents our clock skew tolerance from working + VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + // Allow up to 24 hours of clock skew for self-signed certificates + // This handles cases where system clocks are slightly out of sync + const clockSkewTolerance = 24 * time.Hour + + opts := x509.VerifyOptions{ + Roots: caCertPool, + Intermediates: x509.NewCertPool(), + } + + // Verify each certificate in the chain with clock skew tolerance + for _, rawCert := range rawCerts { + cert, err := x509.ParseCertificate(rawCert) + if err != nil { + return fmt.Errorf("failed to parse certificate: %w", err) + } + + // Check certificate validity with clock skew tolerance + now := time.Now() + if cert.NotBefore.After(now.Add(clockSkewTolerance)) { + // Certificate is too far in the future, reject it + return fmt.Errorf("certificate not valid yet: notBefore is %v, current time is %v (skew tolerance: %v)", + cert.NotBefore, now, clockSkewTolerance) + } + if cert.NotAfter.Before(now.Add(-clockSkewTolerance)) { + // Certificate is too far expired, reject it + return fmt.Errorf("certificate expired: notAfter is %v, current time is %v (skew tolerance: %v)", + cert.NotAfter, now, clockSkewTolerance) + } + + // Try verification with current time + opts.CurrentTime = now + _, err = cert.Verify(opts) + if err != nil { + // Try with positive clock skew (certificate is in the future) + opts.CurrentTime = now.Add(clockSkewTolerance) + _, err = cert.Verify(opts) + if err != nil { + // Try with negative clock skew (certificate is in the past) + opts.CurrentTime = now.Add(-clockSkewTolerance) + _, err = cert.Verify(opts) + if err != nil { + return fmt.Errorf("certificate verification failed: %w", err) + } + } + } + } + return nil + }, }, } diff --git a/internal/utils/instancelock/instancelock.go b/internal/utils/instancelock/instancelock.go new file mode 100644 index 0000000..58e7db2 --- /dev/null +++ b/internal/utils/instancelock/instancelock.go @@ -0,0 +1,120 @@ +// Copyright 2025 The MathWorks, Inc. + +package instancelock + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const lockFileName = "matlab-mcp-core-server.lock" + +// InstanceLock manages a lock file to prevent multiple instances from running +type InstanceLock struct { + lockFilePath string + pid int +} + +// New creates a new instance lock. The lock file will be created in the user's temp directory. +func New() (*InstanceLock, error) { + tempDir := os.TempDir() + lockFilePath := filepath.Join(tempDir, lockFileName) + + return &InstanceLock{ + lockFilePath: lockFilePath, + pid: os.Getpid(), + }, nil +} + +// TryLock attempts to acquire the lock. Returns true if lock was acquired, false if another instance is running. +func (l *InstanceLock) TryLock() (bool, error) { + return l.TryLockWithKill(false) +} + +// TryLockWithKill attempts to acquire the lock, optionally killing the existing instance if one is running. +// If killExisting is true and an existing instance is found, it will be terminated and the lock acquired. +// Returns true if lock was acquired, false if another instance is running and killExisting is false. +func (l *InstanceLock) TryLockWithKill(killExisting bool) (bool, error) { + // Check if lock file exists + if _, err := os.Stat(l.lockFilePath); err == nil { + // Lock file exists, read the PID + pidBytes, err := os.ReadFile(l.lockFilePath) + if err != nil { + // If we can't read it, assume it's stale and try to remove it + os.Remove(l.lockFilePath) + return l.createLock() + } + + existingPID, err := strconv.Atoi(strings.TrimSpace(string(pidBytes))) + if err != nil { + // Invalid PID in lock file, remove it + os.Remove(l.lockFilePath) + return l.createLock() + } + + // Don't kill our own process (shouldn't happen, but safety check) + if existingPID == l.pid { + // We already have the lock + return true, nil + } + + // Check if the process is still running + if l.isProcessRunning(existingPID) { + // Another instance is running + if killExisting { + // Kill the existing instance + if err := l.killProcess(existingPID); err != nil { + return false, fmt.Errorf("failed to kill existing instance (PID %d): %w", existingPID, err) + } + // Wait for the process to exit (with retries) + // We check up to 10 times with 100ms delay between checks (max 1 second wait) + for i := 0; i < 10; i++ { + if !l.isProcessRunning(existingPID) { + break + } + time.Sleep(100 * time.Millisecond) + } + // Remove the lock file (process should be dead now) + os.Remove(l.lockFilePath) + return l.createLock() + } + return false, nil + } + + // Process is dead, remove stale lock file + os.Remove(l.lockFilePath) + } + + // No lock file or stale lock, create new one + return l.createLock() +} + +// createLock creates the lock file with current PID +func (l *InstanceLock) createLock() (bool, error) { + pidStr := strconv.Itoa(l.pid) + err := os.WriteFile(l.lockFilePath, []byte(pidStr), 0o644) + if err != nil { + return false, fmt.Errorf("failed to create lock file: %w", err) + } + return true, nil +} + +// Unlock removes the lock file +func (l *InstanceLock) Unlock() error { + return os.Remove(l.lockFilePath) +} + +// isProcessRunning checks if a process with the given PID is still running +func (l *InstanceLock) isProcessRunning(pid int) bool { + return checkProcessRunningPlatformSpecific(pid) +} + +// killProcess terminates the process with the given PID +func (l *InstanceLock) killProcess(pid int) error { + return killProcessPlatformSpecific(pid) +} + diff --git a/internal/utils/instancelock/instancelock_unix.go b/internal/utils/instancelock/instancelock_unix.go new file mode 100644 index 0000000..d14004e --- /dev/null +++ b/internal/utils/instancelock/instancelock_unix.go @@ -0,0 +1,40 @@ +// Copyright 2025 The MathWorks, Inc. +//go:build !windows + +package instancelock + +import ( + "os" + "syscall" +) + +// checkProcessRunningPlatformSpecific performs Unix-specific process existence check +func checkProcessRunningPlatformSpecific(pid int) bool { + process, err := os.FindProcess(pid) + if err != nil { + return false + } + + // On Unix, sending signal 0 checks if process exists without actually sending a signal + err = process.Signal(syscall.Signal(0)) + return err == nil +} + +// killProcessPlatformSpecific terminates a process on Unix +func killProcessPlatformSpecific(pid int) error { + process, err := os.FindProcess(pid) + if err != nil { + return err + } + + // Send SIGTERM first for graceful shutdown + err = process.Signal(syscall.SIGTERM) + if err != nil { + return err + } + + // Note: We could wait and then send SIGKILL if needed, but for simplicity + // we'll just send SIGTERM and let the OS handle cleanup + return nil +} + diff --git a/internal/utils/instancelock/instancelock_windows.go b/internal/utils/instancelock/instancelock_windows.go new file mode 100644 index 0000000..36e584e --- /dev/null +++ b/internal/utils/instancelock/instancelock_windows.go @@ -0,0 +1,44 @@ +// Copyright 2025 The MathWorks, Inc. +//go:build windows + +package instancelock + +import ( + "golang.org/x/sys/windows" +) + +// checkProcessRunningPlatformSpecific performs Windows-specific process existence check +func checkProcessRunningPlatformSpecific(pid int) bool { + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION, false, uint32(pid)) + if err != nil { + return false + } + defer windows.CloseHandle(handle) + + var exitCode uint32 + err = windows.GetExitCodeProcess(handle, &exitCode) + if err != nil { + return false + } + + // STILL_ACTIVE = 259 on Windows + const STILL_ACTIVE = 259 + return exitCode == STILL_ACTIVE +} + +// killProcessPlatformSpecific terminates a process on Windows +func killProcessPlatformSpecific(pid int) error { + handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, uint32(pid)) + if err != nil { + return err + } + defer windows.CloseHandle(handle) + + err = windows.TerminateProcess(handle, 1) + if err != nil { + return err + } + + return nil +} +