diff --git a/core/Azure.Mcp.Core/src/Services/ProcessExecution/ExternalProcessService.cs b/core/Azure.Mcp.Core/src/Services/ProcessExecution/ExternalProcessService.cs
index 062d4e0641..eb93788bc1 100644
--- a/core/Azure.Mcp.Core/src/Services/ProcessExecution/ExternalProcessService.cs
+++ b/core/Azure.Mcp.Core/src/Services/ProcessExecution/ExternalProcessService.cs
@@ -3,153 +3,730 @@
using System.Diagnostics;
using System.Text;
+using System.Text.Json;
using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
+using static Azure.Mcp.Core.Services.ProcessExecution.ProcessExtensions;
namespace Azure.Mcp.Core.Services.ProcessExecution;
-public class ExternalProcessService : IExternalProcessService
+///
+/// Executes external processes and captures their output in a timeout and cancellation aware way.
+///
+public class ExternalProcessService(ILogger logger) : IExternalProcessService
{
- private readonly Dictionary environmentVariables = [];
-
- ///
+ ///
+ /// Executes an external process and captures its stdout and stderr.
+ ///
+ /// The name or path of the executable to run. In the case of a bare executable name,
+ /// the operating system resolves the executable using its standard search rules (including directories
+ /// listed in the PATH environment variable).
+ /// Command-line arguments to pass to the process.
+ ///
+ /// Optional environment variables. If null, the process inherits the parent environment.
+ ///
+ ///
+ /// Total timeout for the operation (process execution + exit-wait + stdout/stderr drain).
+ /// Must be greater than zero.
+ ///
+ ///
+ /// External cancellation token. When triggered, the process is terminated on a best-effort basis
+ /// and is rethrown.
+ ///
+ ///
+ /// A containing the exit code, stdout, stderr, and the full command.
+ ///
+ ///
+ /// Thrown when is less than or equal to zero.
+ ///
+ ///
+ /// Thrown when is null or empty.
+ ///
+ ///
+ /// Thrown when the process fails to start.
+ ///
+ ///
+ /// Thrown when the operation (process execution and stream draining) exceeds .
+ ///
+ ///
+ /// Thrown when is triggered before the operation completes.
+ ///
+ ///
+ /// Timeout vs. cancellation:
+ ///
+ /// Timeout and cancellation are handled independently:
+ ///
+ ///
+ /// -
+ ///
+ /// is enforced by wrapping the combined
+ /// (exit-wait + stdout + stderr) task in .
+ /// A timeout always results in .
+ /// If the timeout occurs before the process has exited, a best-effort attempt is made to
+ /// terminate the process (and its tree) before throwing.
+ ///
+ ///
+ /// -
+ ///
+ /// represents external caller cancellation. When cancellation is
+ /// signaled, and the process has not yet exited, a best-effort attempt is made to terminate the process
+ /// (and its tree) before rethrowing the original .
+ ///
+ ///
+ ///
+ ///
+ /// This separation avoids the common ambiguity where timeouts are implemented by canceling the caller’s token.
+ /// Here: always means timeout;
+ /// always means external cancellation.
+ ///
+ ///
+ ///
+ /// Diagnostic information on exceptions:
+ /// When a timeout or external cancellation occurs, this method attaches structured diagnostic
+ /// information to the thrown or
+ /// via the exception’s Data dictionary. This
+ /// information does not alter exception semantics or stack traces, and exists solely to aid
+ /// debugging. The following fields are always included:
+ ///
+ ///
+ ///
+ /// - "ProcessName" — the process name if available, or "<unknown>".
+ /// - "ProcessId" — the process ID if available, or -1.
+ /// - "ProcessExitStatus" — one of Exited, NotExited, or Indeterminate,
+ /// indicating the observed exit state of the process "before" any kill attempt.
+ ///
+ ///
+ ///
+ /// Additional diagnostic fields are included only when relevant:
+ ///
+ ///
+ ///
+ /// -
+ ///
+ /// "ProcessKillException" — present only when the process was observed to be
+ /// NotExited and a best-effort attempt to terminate it
+ /// (via ) failed. The value is the caught exception.
+ ///
+ ///
+ /// -
+ ///
+ /// "ProcessExitCheckException" — present only when the process exit state could not be
+ /// determined because itself threw during the exit check. This
+ /// captures the original exception that made the exit status Indeterminate.
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// These fields are useful for diagnosing cases such as processes failing to terminate, OS-level handle
+ /// failures, corrupted process state, or unexpected platform behavior. All diagnostic data is attached
+ /// without modifying the original exception type, message, stack trace, or cancellation semantics.
+ ///
+ ///
+ ///
+ /// The method does not interpret success or failure using .
+ /// The exit code is included in the , and the caller decides how to interpret it.
+ ///
+ ///
+ ///
public async Task ExecuteAsync(
- string executablePath,
+ string fileName,
string arguments,
- int timeoutSeconds = 300,
- IEnumerable? customPaths = null,
+ IDictionary? environmentVariables = null,
+ int operationTimeoutSeconds = 300,
CancellationToken cancellationToken = default)
{
- ArgumentException.ThrowIfNullOrEmpty(executablePath);
+ var operationTimeout = ValidateTimeout(operationTimeoutSeconds);
+
+ using Process process = CreateProcess(fileName, arguments, environmentVariables);
+ using ProcessStreamReader stdoutReader = new(process, isErrorStream: false, logger);
+ using ProcessStreamReader stderrReader = new(process, isErrorStream: true, logger);
+
+ if (!process.Start())
+ {
+ throw new InvalidOperationException($"Failed to start process: {fileName}");
+ }
+
+ Task stdoutTask = stdoutReader.ReadToEndAsync();
+ Task stderrTask = stderrReader.ReadToEndAsync();
+ Task exitTask = process.WaitForExitAsync(cancellationToken);
+
+ Task operation = Task.WhenAll(exitTask, stdoutTask, stderrTask);
+
+ try
+ {
+ await operation.WaitAsync(operationTimeout, cancellationToken).ConfigureAwait(false);
+ }
+ catch (TimeoutException)
+ {
+ // Timeout may be thrown either:
+ // - Case A: before the process had exited, or
+ // - Case B: after the process had already exited, but before streams were fully drained.
+ throw HandleTimeout(process, operationTimeout, fileName, arguments);
+ }
+ catch (OperationCanceledException oce) when (cancellationToken.IsCancellationRequested)
+ {
+ // Cancellation was explicitly requested by the caller (not a timeout).
+ // OCE may be thrown either:
+ // - Case A: by WaitForExitAsync while the process is still running, or
+ // - Case B: by WaitAsync after the process has already exited.
+ HandleCancellation(process, fileName, arguments, oce);
+ // 'throw;' here preserves the original stack trace from where the OCE was first thrown,
+ // inside WaitAsync or WaitForExitAsync not from this catch block.
+ throw;
+ }
+
+ // The earlier await on Task.WhenAll(...) guarantees that stdoutTask and stderrTask have already run
+ // to completion. The .Result simply retrieves their already-computed output without any blocking.
+ var stdout = stdoutTask.Result.TrimEnd();
+ var stderr = stderrTask.Result.TrimEnd();
+
+ // Normal completion: the process has exited, and stdout/stderr have fully drained.
+ return new ProcessResult(
+ process.ExitCode,
+ stdout,
+ stderr,
+ $"{fileName} {arguments}");
+
+ // The `using` declarations at the top ensure that both ProcessStreamReader and Process are disposed on
+ // every exit path—normal completion, timeout, or cancellation — unsubscribing handlers and releasing OS
+ // resources.
+ }
- if (!File.Exists(executablePath))
+ public JsonElement ParseJsonOutput(ProcessResult result)
+ {
+ if (result.ExitCode != 0)
{
- throw new FileNotFoundException($"Executable not found at path: {executablePath}");
+ var error = new ParseError(
+ result.ExitCode,
+ result.Error,
+ result.Command);
+
+ return JsonSerializer.SerializeToElement(
+ error,
+ ServicesJsonContext.Default.ParseError);
}
- var processStartInfo = new ProcessStartInfo
+ try
{
- FileName = executablePath,
+ using var jsonDocument = JsonDocument.Parse(result.Output);
+ return jsonDocument.RootElement.Clone();
+ }
+ catch
+ {
+ var fallback = new ParseOutput(result.Output);
+ return JsonSerializer.SerializeToElement(
+ fallback,
+ ServicesJsonContext.Default.ParseOutput);
+ }
+ }
+
+ internal record ParseError(
+ int ExitCode,
+ string Error,
+ string Command);
+
+ internal record ParseOutput(
+ [property: JsonPropertyName("output")]
+ string Output);
+
+ private static TimeSpan ValidateTimeout(int operationTimeoutSeconds)
+ {
+ if (operationTimeoutSeconds <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(operationTimeoutSeconds), "Timeout must be a positive number of seconds.");
+ }
+ return TimeSpan.FromSeconds(operationTimeoutSeconds);
+ }
+
+ ///
+ /// Creates and configures a for execution with redirected stdout/stderr/stdin and
+ /// applied environment variables.
+ ///
+ private static Process CreateProcess(string fileName, string arguments, IDictionary? environmentVariables)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(fileName);
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = fileName,
Arguments = arguments,
+
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
+
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
- foreach (var keyValuePair in environmentVariables)
+ if (environmentVariables is not null)
{
- processStartInfo.EnvironmentVariables[keyValuePair.Key] = keyValuePair.Value;
+ foreach (var pair in environmentVariables)
+ {
+ startInfo.EnvironmentVariables[pair.Key] = pair.Value;
+ }
}
- using var outputWaitHandle = new AutoResetEvent(false);
- using var errorWaitHandle = new AutoResetEvent(false);
- using var process = new Process { StartInfo = processStartInfo };
-
- var outputBuilder = new StringBuilder();
- var errorBuilder = new StringBuilder();
-
- process.OutputDataReceived += (sender, e) =>
+ return new Process
{
- if (e.Data == null)
- outputWaitHandle.Set();
- else
- outputBuilder.AppendLine(e.Data);
+ StartInfo = startInfo,
+ EnableRaisingEvents = true
};
+ }
+
+ private TimeoutException HandleTimeout(Process process, TimeSpan timeout, string executablePath, string arguments)
+ {
+ // Get the pre-kill exit state and any kill error (if termination was attempted).
+ (ExitCheckResult exitCheck, Exception? killException) = process.TryKill(logger);
+ string command = $"{executablePath} {arguments}";
- process.ErrorDataReceived += (sender, e) =>
+ TimeoutException exception;
+
+ switch (exitCheck.Status)
{
- if (e.Data == null)
- errorWaitHandle.Set();
- else
- errorBuilder.AppendLine(e.Data);
- };
+ case ExitStatus.NotExited:
+ // Timeout occurred before the process had exited: the process itself exceeded the timeout.
+ exception = new TimeoutException($"Process execution timed out after {timeout.TotalSeconds} seconds: {command}");
+ if (killException is not null)
+ {
+ exception.Data["ProcessKillException"] = killException;
+ logger.LogWarning(killException, "Failed to kill process after timeout for command: {Command}", command);
+ }
+ break;
+
+ case ExitStatus.Exited:
+ // Timeout occurred after the process had already exited, but before streams were fully drained.
+ exception = new TimeoutException($"Process streams draining timed out after {timeout.TotalSeconds} seconds: {command}");
+ break;
+
+ case ExitStatus.Indeterminate:
+ // Could not determine exit state due to an exception from Process.HasExited (no kill was attempted).
+ exception = new TimeoutException($"Process execution or streams draining timed out after {timeout.TotalSeconds} seconds: {command}");
+ exception.Data["ProcessExitCheckException"] = exitCheck.CheckException;
+ logger.LogWarning(exitCheck.CheckException, "Could not determine process exit state after the timeout for command: {Command}", command);
+ break;
+
+ default:
+ throw new InvalidOperationException($"Unexpected exit status: {exitCheck.Status}");
+ }
+
+ exception.Data["ProcessName"] = process.SafeName();
+ exception.Data["ProcessId"] = process.SafeId();
+ exception.Data["ProcessExitStatus"] = exitCheck.Status.ToString();
- process.Start();
- process.BeginOutputReadLine();
- process.BeginErrorReadLine();
+ return exception;
+ }
+
+ private void HandleCancellation(Process process, string executablePath, string arguments, OperationCanceledException exception)
+ {
+ // Get the pre-kill exit state and any kill error (if termination was attempted).
+ (ExitCheckResult exitCheck, Exception? killException) = process.TryKill(logger);
+ string command = $"{executablePath} {arguments}";
- var waitTask = Task.WhenAll(
- Task.Run(() => process.WaitForExit(timeoutSeconds * 1000), cancellationToken),
- Task.Run(
- () =>
+ switch (exitCheck.Status)
+ {
+ case ExitStatus.NotExited:
+ // Cancellation occurred before the process had exited.
+ if (killException is not null)
{
- outputWaitHandle.WaitOne(1000);
- errorWaitHandle.WaitOne(1000);
- },
- cancellationToken)
- );
+ exception.Data["ProcessKillException"] = killException;
+ logger.LogWarning(killException, "Failed to kill process after cancellation for command: {Command}", command);
+ }
+ break;
- try
+ case ExitStatus.Exited:
+ // Process already exited
+ break;
+
+ case ExitStatus.Indeterminate:
+ // Could not determine exit state due to an exception from Process.HasExited (no kill was attempted).
+ exception.Data["ProcessExitCheckException"] = exitCheck.CheckException;
+ logger.LogWarning(exitCheck.CheckException, "Could not determine process exit state during cancellation for command: {Command}", command);
+ break;
+
+ default:
+ throw new InvalidOperationException($"Unexpected exit status: {exitCheck.Status}");
+ }
+
+ exception.Data["ProcessName"] = process.SafeName();
+ exception.Data["ProcessId"] = process.SafeId();
+ exception.Data["ProcessExitStatus"] = exitCheck.Status.ToString();
+
+ // we deliberately preserve and rethrow (via 'throw;') the original OCE at call site.
+ }
+
+ ///
+ /// Reads either stdout or stderr from a asynchronously.
+ /// Handlers are attached in the constructor, and reading begins when
+ /// is called after the process has started.
+ ///
+ ///
+ ///
+ /// Intended usage pattern:
+ ///
+ ///
+ /// - Create and configure the with redirected streams.
+ /// - Construct (handlers attach immediately).
+ /// - Start the process.
+ /// - Call to begin event-driven reading.
+ /// - Await the returned task to obtain the full stream content.
+ ///
+ ///
+ ///
+ /// The reader does not handle cancellation directly - the underlying
+ /// owns the reader threads, which naturally terminate when the process exits or is killed.
+ /// This type:
+ ///
+ ///
+ /// - Accumulates received lines into a buffer.
+ /// - Completes when e.Data == null, the signal that the stream closed.
+ /// -
+ /// Includes a safety-net completion in so callers cannot hang if
+ /// a close event is never raised (only relevant if used outside ExecuteAsync).
+ ///
+ ///
+ ///
+ private sealed class ProcessStreamReader : IDisposable
+ {
+ private readonly Process _process;
+ private readonly bool _isErrorStream;
+ private readonly ILogger _logger;
+ private readonly StringBuilder _buffer = new();
+ private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private readonly DataReceivedEventHandler _handler;
+ private bool _readingStarted;
+ private bool _disposed;
+
+ public ProcessStreamReader(Process process, bool isErrorStream, ILogger logger)
{
- await waitTask;
+ this._process = process ?? throw new ArgumentNullException(nameof(process));
+ this._isErrorStream = isErrorStream;
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+
+ _handler = (_, e) =>
+ {
+ if (e.Data is null)
+ {
+ // Stream closed – finalize accumulated output and complete.
+ _tcs.TrySetResult(_buffer.ToString());
+ }
+ else
+ {
+ _buffer.AppendLine(e.Data);
+ }
+ };
+
+ if (isErrorStream)
+ {
+ process.ErrorDataReceived += _handler;
+ }
+ else
+ {
+ process.OutputDataReceived += _handler;
+ }
}
- catch (OperationCanceledException)
+
+ ///
+ /// Begins asynchronous reading of the associated stream.
+ /// Must be called only after has successfully completed.
+ ///
+ ///
+ /// This method does not accept a because the underlying
+ /// BeginOutputReadLine / BeginErrorReadLine APIs are event-driven and do not
+ /// support token-based cancellation.
+ ///
+ /// Cancellation of the overall external process operation is handled by ExecuteAsync,
+ /// which terminates the process on timeout or external cancellation. Once the process exits
+ /// (naturally or via kill), the stream read completes automatically.
+ ///
+ /// If ProcessStreamReader is ever used outside the normal ExecuteAsync workflow,
+ /// provides a safety net by completing the task even if the final
+ /// e.Data == null callback was never raised.
+ ///
+ /// A task that completes when the stream has fully drained.
+ public Task ReadToEndAsync()
{
- if (!process.HasExited)
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ if (_readingStarted)
{
- process.Kill();
+ throw new InvalidOperationException("StartReading has already been called for this reader.");
}
- throw;
+ _readingStarted = true;
+
+ if (_isErrorStream)
+ {
+ _process.BeginErrorReadLine();
+ }
+ else
+ {
+ _process.BeginOutputReadLine();
+ }
+
+ return _tcs.Task;
}
- if (!process.HasExited)
+ ///
+ /// Disposes the stream reader by unsubscribing the data-received handler and ensuring
+ /// that the task returned by is completed as a safety net.
+ ///
+ ///
+ /// In the normal ExecuteAsync workflow, the read task will already have completed
+ /// before runs:
+ ///
+ /// - Success path — the stream has fully drained.
+ /// - Timeout or cancellation — ExecuteAsync has already raised an exception.
+ ///
+ ///
+ /// In these cases, the fallback completion inside is a harmless no-op.
+ /// Its purpose is to guarantee that callers of never block indefinitely if
+ /// ProcessStreamReader is used outside the intended ExecuteAsync flow and the
+ /// final e.Data == null callback is never delivered.
+ ///
+ public void Dispose()
{
- process.Kill();
- throw new TimeoutException($"Process execution timed out after {timeoutSeconds} seconds");
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ try
+ {
+ if (_isErrorStream)
+ {
+ _process.ErrorDataReceived -= _handler;
+ }
+ else
+ {
+ _process.OutputDataReceived -= _handler;
+ }
+ }
+ catch (Exception ex)
+ {
+ // Unsubscribing the handlers is a best-effort cleanup step; log and swallow any exceptions to avoid disposal throwing.
+ _logger.LogDebug(
+ ex,
+ "Unsubscribe from {StreamType} stream during disposal was skipped. Process: {ProcessName}, PID: {Pid}.",
+ StreamType,
+ _process.SafeName(),
+ _process.SafeId());
+ }
+
+ // Safety net: if the DataReceived handler _handler never observed the final e.Data == null
+ // and therefore never completed the read task, force completion here.
+ //
+ // We intentionally avoid calling ToString() on the shared StringBuilder _buffer to prevent
+ // any cross-thread races with AppendLine() inside the event handler. In all normal
+ // ExecuteAsync scenarios, the task will already be completed before Dispose() runs,
+ // making completion here a no-op.
+ //
+ // In timeout or cancellation scenarios, the caller receives an exception and does not
+ // consume stream output, so try completing with an empty string is sufficient and avoids
+ // touching potentially-mutating state.
+ if (_tcs.TrySetResult(string.Empty))
+ {
+ _logger.LogDebug(
+ "ProcessStreamReader for {StreamType} (Process: {ProcessName}, PID: {Pid}) completed during disposal.",
+ StreamType,
+ _process.SafeName(),
+ _process.SafeId());
+ }
}
- return new ProcessResult(
- process.ExitCode,
- outputBuilder.ToString().TrimEnd(),
- errorBuilder.ToString().TrimEnd(),
- $"{executablePath} {arguments}");
+ private string StreamType => _isErrorStream ? "stderr" : "stdout";
}
+}
- public JsonElement ParseJsonOutput(ProcessResult result)
+///
+/// Extensions for safe process inspection and kill operations.
+///
+internal static class ProcessExtensions
+{
+ public static int SafeId(this Process process)
{
- if (result.ExitCode != 0)
+ try
{
- var error = new ParseError(
- result.ExitCode,
- result.Error,
- result.Command
- );
- return JsonSerializer.SerializeToElement(error, ServicesJsonContext.Default.ParseError);
+ return process.Id;
+ }
+ catch
+ {
+ return -1;
}
+ }
+ public static string SafeName(this Process process)
+ {
try
{
- using var jsonDocument = JsonDocument.Parse(result.Output);
- return jsonDocument.RootElement.Clone();
+ return process.ProcessName;
}
catch
{
- return JsonSerializer.SerializeToElement(new ParseOutput(result.Output), ServicesJsonContext.Default.ParseOutput);
+ return "";
}
}
- internal record ParseError(
- int ExitCode,
- string Error,
- string Command
- );
-
- internal record ParseOutput([property: JsonPropertyName("output")] string Output);
-
- public void SetEnvironmentVariables(IDictionary variables)
+ ///
+ /// Gets the exit state of a process, defensively handling exceptions that may be thrown by .
+ ///
+ /// See official docs:
+ /// https://learn.microsoft.com/dotnet/api/system.diagnostics.process.hasexited
+ ///
+ ///
+ /// The process to check.
+ /// Logger for diagnostic messages.
+ ///
+ /// An indicating:
+ ///
+ /// - with null exception if the process has exited.
+ /// - with null exception if the process has not exited.
+ /// - with null exception if is thrown.
+ /// -
+ /// with the exception if or is thrown.
+ ///
+ ///
+ ///
+ public static ExitCheckResult CheckExitState(this Process process, ILogger logger)
{
- if (variables == null)
+ try
{
- return;
+ return process.HasExited
+ ? new ExitCheckResult(ExitStatus.Exited, CheckException: null)
+ : new ExitCheckResult(ExitStatus.NotExited, CheckException: null);
}
+ catch (InvalidOperationException checkException)
+ {
+ // Official docs: "No process is associated with this object." - treat as "already gone".
+ logger.LogDebug(
+ checkException,
+ "Process.HasExited reported no associated process. Treating as already exited. " +
+ "Process: {ProcessName}, PID: {Pid}", process.SafeName(), process.SafeId());
+ return new ExitCheckResult(ExitStatus.Exited, CheckException: null);
+ }
+ catch (System.ComponentModel.Win32Exception checkException)
+ {
+ // Failure to read status (access denied, invalid handle, etc.).
+ return new ExitCheckResult(ExitStatus.Indeterminate, checkException);
+ }
+ catch (NotSupportedException checkException)
+ {
+ // Remote process or unsupported scenario – not a valid case for Azure MCP Server.
+ return new ExitCheckResult(ExitStatus.Indeterminate, checkException);
+ }
+ }
- foreach (var pair in variables)
+ ///
+ /// Best-effort attempt to terminate the process (and its tree), returning the exit state observed before
+ /// the kill attempt and any exception from the kill attempt.
+ ///
+ /// See official docs:
+ /// https://learn.microsoft.com/dotnet/api/system.diagnostics.process.Kill
+ ///
+ ///
+ /// The process to terminate.
+ /// Logger for diagnostic messages.
+ ///
+ ///
+ /// The returned reflects the state observed before the kill attempt.
+ /// If is reported, this method calls .
+ ///
+ /// Kill may throw:
+ ///
+ /// -
+ /// – no process is associated with this object (for example, it has
+ /// already exited or the handle is invalid). In this case, the exception is logged as debug information and
+ /// is not treated as a kill failure, since there is nothing left to terminate.
+ ///
+ /// -
+ /// or
+ /// – treated as genuine kill failures and returned to the caller for
+ /// diagnostic purposes.
+ ///
+ ///
+ ///
+ public static (ExitCheckResult ExitCheck, Exception? KillException) TryKill(this Process process, ILogger logger)
+ {
+ var exitCheck = process.CheckExitState(logger);
+
+ if (exitCheck.Status == ExitStatus.NotExited)
{
- environmentVariables[pair.Key] = pair.Value;
+ try
+ {
+ process.Kill(entireProcessTree: true);
+ return (exitCheck, null);
+ }
+ catch (InvalidOperationException e)
+ {
+ // Official docs: "No process is associated with this object." - treat as "already gone".
+ logger.LogDebug(
+ e,
+ "Process.Kill reported no associated process. Treating as already exited. " +
+ "Process: {ProcessName}, PID: {Pid}", process.SafeName(), process.SafeId());
+
+ // Considered success from a termination perspective: nothing left to kill.
+ return (exitCheck, null);
+ }
+ catch (System.ComponentModel.Win32Exception e)
+ {
+ // Failure to terminate (access denied, invalid handle, etc.).
+ return (exitCheck, e);
+ }
+ catch (NotSupportedException e)
+ {
+ // Remote process or unsupported scenario – not a valid case for Azure MCP Server.
+ return (exitCheck, e);
+ }
}
+
+ // Process has already exited or exit state was indeterminate; nothing to kill.
+ return (exitCheck, null);
+ }
+
+ ///
+ /// Represents the exit status of a process.
+ ///
+ public enum ExitStatus
+ {
+ ///
+ /// The process has exited.
+ ///
+ Exited,
+
+ ///
+ /// The process has not exited.
+ ///
+ NotExited,
+
+ ///
+ /// The process exit status could not be determined because
+ /// threw an exception during the check.
+ ///
+ Indeterminate
}
+
+ ///
+ /// Represents the observed exit state of a process as determined by
+ /// .
+ ///
+ /// This captures:
+ ///
+ /// -
+ ///
+ /// The
+ ///
+ ///
+ /// -
+ ///
+ /// An associated exception only when the status is
+ ///
+ ///
+ ///
+ ///
+ public record ExitCheckResult(ExitStatus Status, Exception? CheckException);
}
diff --git a/core/Azure.Mcp.Core/src/Services/ProcessExecution/IExternalProcessService.cs b/core/Azure.Mcp.Core/src/Services/ProcessExecution/IExternalProcessService.cs
index 15a435e404..e4c17a1317 100644
--- a/core/Azure.Mcp.Core/src/Services/ProcessExecution/IExternalProcessService.cs
+++ b/core/Azure.Mcp.Core/src/Services/ProcessExecution/IExternalProcessService.cs
@@ -12,19 +12,29 @@ public record ProcessResult(
public interface IExternalProcessService
{
///
- /// Executes an external process and returns the result
+ /// Executes an external process and captures its standard output and error streams.
///
- /// Name of the executable to find in PATH or common install locations
- /// Arguments to pass to the executable
- /// Timeout in seconds
- /// Optional additional paths to search for the executable
- /// A token to cancel the operation.
- /// Process execution result containing exit code, output and error streams
+ /// Full path to the executable to run.
+ /// Command-line arguments to pass to the executable.
+ ///
+ /// Optional environment variables to set for the process. If null or not provided, no additional
+ /// environment variables will be set beyond those inherited from the parent process.
+ ///
+ ///
+ /// Total timeout in seconds for the entire operation, including process execution and output capture.
+ /// Defaults to 300 seconds (5 minutes). Must be greater than zero.
+ ///
+ ///
+ /// Cancellation token to abort the operation. The process will be terminated if cancellation is requested.
+ ///
+ ///
+ /// A containing the exit code, standard output, standard error, and command string.
+ ///
Task ExecuteAsync(
- string executableName,
+ string executablePath,
string arguments,
- int timeoutSeconds = 300,
- IEnumerable? customPaths = null,
+ IDictionary? environmentVariables = default,
+ int operationTimeoutSeconds = 300,
CancellationToken cancellationToken = default);
///
@@ -33,10 +43,4 @@ Task ExecuteAsync(
/// Process execution result
/// Parsed JSON element or formatted error object if parsing fails
JsonElement ParseJsonOutput(ProcessResult result);
-
- ///
- /// Sets environment variables for the process execution
- /// ///
- /// Dictionary of environment variables to set
- void SetEnvironmentVariables(IDictionary variables);
}
diff --git a/servers/Azure.Mcp.Server/tests/Azure.Mcp.Server.UnitTests/Infrastructure/CommandMetadataSyncTests.cs b/servers/Azure.Mcp.Server/tests/Azure.Mcp.Server.UnitTests/Infrastructure/CommandMetadataSyncTests.cs
index 48c00df9c1..94cfaee18d 100644
--- a/servers/Azure.Mcp.Server/tests/Azure.Mcp.Server.UnitTests/Infrastructure/CommandMetadataSyncTests.cs
+++ b/servers/Azure.Mcp.Server/tests/Azure.Mcp.Server.UnitTests/Infrastructure/CommandMetadataSyncTests.cs
@@ -2,6 +2,7 @@
// Licensed under the MIT License.
using Azure.Mcp.Core.Services.ProcessExecution;
+using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Azure.Mcp.Server.UnitTests.Infrastructure;
@@ -31,7 +32,7 @@ public async Task AzCommandsMetadata_Should_Be_Synchronized()
var originalContent = File.ReadAllText(docsPath);
// Act - Run the update script using ExternalProcessService
- var processService = new ExternalProcessService();
+ var processService = new ExternalProcessService(NullLogger.Instance);
var pwshPath = FindPowerShellExecutable();
var arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{updateScriptPath}\" -AzmcpPath \"{azmcpPath}\" -DocsPath \"{docsPath}\"";
diff --git a/tools/Azure.Mcp.Tools.Extension/src/Commands/AzCommand.cs b/tools/Azure.Mcp.Tools.Extension/src/Commands/AzCommand.cs
index 0f1da34a9c..8ef30ea0b9 100644
--- a/tools/Azure.Mcp.Tools.Extension/src/Commands/AzCommand.cs
+++ b/tools/Azure.Mcp.Tools.Extension/src/Commands/AzCommand.cs
@@ -138,7 +138,9 @@ private async Task AuthenticateWithAzureCredentialsAsync(IExternalProcessS
var azPath = FindAzCliPath() ?? throw new FileNotFoundException("Azure CLI executable not found in PATH or common installation locations. Please ensure Azure CLI is installed.");
var loginCommand = $"login --service-principal -u {credentials.ClientId} -p {credentials.ClientSecret} --tenant {credentials.TenantId}";
- var result = await processService.ExecuteAsync(azPath, loginCommand, 60, cancellationToken: cancellationToken);
+ var result = await processService.ExecuteAsync(azPath, loginCommand,
+ operationTimeoutSeconds: 60,
+ cancellationToken: cancellationToken);
if (result.ExitCode != 0)
{
@@ -180,7 +182,9 @@ public override async Task ExecuteAsync(CommandContext context,
await AuthenticateWithAzureCredentialsAsync(processService, _logger, cancellationToken);
var azPath = FindAzCliPath() ?? throw new FileNotFoundException("Azure CLI executable not found in PATH or common installation locations. Please ensure Azure CLI is installed.");
- var result = await processService.ExecuteAsync(azPath, command, _processTimeoutSeconds, cancellationToken: cancellationToken);
+ var result = await processService.ExecuteAsync(azPath, command,
+ operationTimeoutSeconds: _processTimeoutSeconds,
+ cancellationToken: cancellationToken);
if (result.ExitCode != 0)
{
diff --git a/tools/Azure.Mcp.Tools.Extension/src/Commands/AzqrCommand.cs b/tools/Azure.Mcp.Tools.Extension/src/Commands/AzqrCommand.cs
index 89d30397d7..73ecc8a17d 100644
--- a/tools/Azure.Mcp.Tools.Extension/src/Commands/AzqrCommand.cs
+++ b/tools/Azure.Mcp.Tools.Extension/src/Commands/AzqrCommand.cs
@@ -103,7 +103,9 @@ public override async Task ExecuteAsync(CommandContext context,
command += " --json";
var processService = context.GetService();
- var result = await processService.ExecuteAsync(azqrPath, command, _processTimeoutSeconds, cancellationToken: cancellationToken);
+ var result = await processService.ExecuteAsync(azqrPath, command,
+ operationTimeoutSeconds: _processTimeoutSeconds,
+ cancellationToken: cancellationToken);
if (result.ExitCode != 0)
{
diff --git a/tools/Azure.Mcp.Tools.Extension/tests/Azure.Mcp.Tools.Extension.UnitTests/AzqrCommandTests.cs b/tools/Azure.Mcp.Tools.Extension/tests/Azure.Mcp.Tools.Extension.UnitTests/AzqrCommandTests.cs
index b97f9d21e6..3e68e81b00 100644
--- a/tools/Azure.Mcp.Tools.Extension/tests/Azure.Mcp.Tools.Extension.UnitTests/AzqrCommandTests.cs
+++ b/tools/Azure.Mcp.Tools.Extension/tests/Azure.Mcp.Tools.Extension.UnitTests/AzqrCommandTests.cs
@@ -73,8 +73,8 @@ public async Task ExecuteAsync_ReturnsSuccessResult_WhenScanSucceeds()
_processService.ExecuteAsync(
Arg.Any(),
Arg.Any(),
+ Arg.Any?>(),
Arg.Any(),
- Arg.Any>(),
Arg.Any())
.Returns(new ProcessResult(0, expectedOutput, string.Empty, $"scan --subscription-id {mockSubscriptionId}"));
@@ -90,8 +90,8 @@ public async Task ExecuteAsync_ReturnsSuccessResult_WhenScanSucceeds()
await _processService.Received().ExecuteAsync(
Arg.Any(),
Arg.Any(),
+ Arg.Any?>(),
Arg.Any(),
- Arg.Any>(),
Arg.Any());
}
finally