diff --git a/Microsoft.Testing.Platform.slnf b/Microsoft.Testing.Platform.slnf
index 5196d2338c..82a8c2dac3 100644
--- a/Microsoft.Testing.Platform.slnf
+++ b/Microsoft.Testing.Platform.slnf
@@ -26,11 +26,13 @@
"src\\Platform\\Microsoft.Testing.Extensions.VideoRecorder\\Microsoft.Testing.Extensions.VideoRecorder.csproj",
"src\\Platform\\Microsoft.Testing.Platform.AI\\Microsoft.Testing.Platform.AI.csproj",
"src\\Platform\\Microsoft.Testing.Platform.MSBuild\\Microsoft.Testing.Platform.MSBuild.csproj",
+ "src\\Platform\\Microsoft.Testing.Platform.ServerMode.Client.Sources\\Microsoft.Testing.Platform.ServerMode.Client.Sources.csproj",
"src\\Platform\\Microsoft.Testing.Platform\\Microsoft.Testing.Platform.csproj",
"test\\IntegrationTests\\Microsoft.Testing.Platform.Acceptance.IntegrationTests\\Microsoft.Testing.Platform.Acceptance.IntegrationTests.csproj",
"test\\UnitTests\\Microsoft.Testing.Extensions.UnitTests\\Microsoft.Testing.Extensions.UnitTests.csproj",
"test\\UnitTests\\Microsoft.Testing.Extensions.VSTestBridge.UnitTests\\Microsoft.Testing.Extensions.VSTestBridge.UnitTests.csproj",
"test\\UnitTests\\Microsoft.Testing.Platform.MSBuild.UnitTests\\Microsoft.Testing.Platform.MSBuild.UnitTests.csproj",
+ "test\\UnitTests\\Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests\\Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests.csproj",
"test\\UnitTests\\Microsoft.Testing.Platform.UnitTests\\Microsoft.Testing.Platform.UnitTests.csproj",
"test\\Utilities\\Microsoft.Testing.TestInfrastructure\\Microsoft.Testing.TestInfrastructure.csproj"
]
diff --git a/NonWindowsTests.slnf b/NonWindowsTests.slnf
index fef92a2a3a..dfff67fd32 100644
--- a/NonWindowsTests.slnf
+++ b/NonWindowsTests.slnf
@@ -32,6 +32,7 @@
"src\\Platform\\Microsoft.Testing.Extensions.VideoRecorder\\Microsoft.Testing.Extensions.VideoRecorder.csproj",
"src\\Platform\\Microsoft.Testing.Platform.AI\\Microsoft.Testing.Platform.AI.csproj",
"src\\Platform\\Microsoft.Testing.Platform.MSBuild\\Microsoft.Testing.Platform.MSBuild.csproj",
+ "src\\Platform\\Microsoft.Testing.Platform.ServerMode.Client.Sources\\Microsoft.Testing.Platform.ServerMode.Client.Sources.csproj",
"src\\Platform\\Microsoft.Testing.Platform\\Microsoft.Testing.Platform.csproj",
"src\\TestFramework\\TestFramework.Extensions\\TestFramework.Extensions.csproj",
"src\\TestFramework\\TestFramework.SourceGeneration\\TestFramework.SourceGeneration.csproj",
@@ -42,6 +43,7 @@
"test\\UnitTests\\Microsoft.Testing.Extensions.UnitTests\\Microsoft.Testing.Extensions.UnitTests.csproj",
"test\\UnitTests\\Microsoft.Testing.Extensions.VSTestBridge.UnitTests\\Microsoft.Testing.Extensions.VSTestBridge.UnitTests.csproj",
"test\\UnitTests\\Microsoft.Testing.Platform.MSBuild.UnitTests\\Microsoft.Testing.Platform.MSBuild.UnitTests.csproj",
+ "test\\UnitTests\\Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests\\Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests.csproj",
"test\\UnitTests\\Microsoft.Testing.Platform.UnitTests\\Microsoft.Testing.Platform.UnitTests.csproj",
"test\\Utilities\\Microsoft.Testing.TestInfrastructure\\Microsoft.Testing.TestInfrastructure.csproj"
]
diff --git a/TestFx.slnx b/TestFx.slnx
index 90f6cb8ac9..b409dbff2c 100644
--- a/TestFx.slnx
+++ b/TestFx.slnx
@@ -58,6 +58,7 @@
+
@@ -115,6 +116,7 @@
+
diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpClientLogger.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpClientLogger.cs
new file mode 100644
index 0000000000..739422a75b
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpClientLogger.cs
@@ -0,0 +1,102 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Microsoft.Testing.Platform.ServerMode.Client;
+
+///
+/// Severity of a diagnostic message emitted by the MTP server client itself (transport, handshake, process
+/// lifetime). This is the client's own trace channel and is intentionally decoupled from the platform's
+/// LogLevel and from the client/log notifications the server forwards.
+///
+internal enum MtpClientLogLevel
+{
+ /// Extremely verbose tracing (raw frames, per-message correlation).
+ Trace,
+
+ /// Diagnostic detail useful when debugging the client.
+ Debug,
+
+ /// Informational lifecycle messages (connected, initialized, exited).
+ Information,
+
+ /// A recoverable problem the caller may want to know about.
+ Warning,
+
+ /// A failure in the client transport or process handling.
+ Error,
+}
+
+///
+/// Sink for the client's own diagnostic messages. Consumers inject their host logger (vstest's tracing, VS
+/// output, C# Dev Kit logging) so the package carries no logging dependency of its own — in particular NO
+/// EqtTrace and NO Visual Studio logger.
+///
+internal interface IMtpClientLogger
+{
+ ///
+ /// Writes a diagnostic message. Implementations must be thread-safe: the client calls this from its
+ /// background read loop as well as from caller threads.
+ ///
+ /// Severity of the message.
+ /// The already-formatted message text.
+ void Log(MtpClientLogLevel level, string message);
+}
+
+///
+/// An that forwards to a delegate, for callers that prefer a lambda over a type.
+///
+/// The delegate invoked for each message.
+internal sealed class DelegateMtpClientLogger(Action log) : IMtpClientLogger
+{
+ private readonly Action _log = log ?? throw new ArgumentNullException(nameof(log));
+
+ ///
+ public void Log(MtpClientLogLevel level, string message)
+ => _log(level, message);
+}
+
+///
+/// An that discards everything. Used when the caller supplies no logger.
+///
+internal sealed class NullMtpClientLogger : IMtpClientLogger
+{
+ /// Gets the shared instance.
+ public static NullMtpClientLogger Instance { get; } = new();
+
+ private NullMtpClientLogger()
+ {
+ }
+
+ ///
+ public void Log(MtpClientLogLevel level, string message)
+ {
+ // Intentionally empty.
+ }
+}
+
+///
+/// Extension helpers for .
+///
+internal static class MtpClientLoggerExtensions
+{
+ ///
+ /// Logs a diagnostic message, swallowing any exception the consumer's logger throws. Diagnostics must
+ /// never destabilize the client: a logger that throws must not fail a request, skip process teardown, or
+ /// fault the read loop. Every transport/lifetime log site goes through this instead of calling
+ /// directly.
+ ///
+ /// The logger to write to.
+ /// Severity of the message.
+ /// The already-formatted message text.
+ public static void SafeLog(this IMtpClientLogger logger, MtpClientLogLevel level, string message)
+ {
+ try
+ {
+ logger.Log(level, message);
+ }
+ catch (Exception)
+ {
+ // A logger must never destabilize the client transport or lifecycle.
+ }
+ }
+}
diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpServerClient.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpServerClient.cs
new file mode 100644
index 0000000000..f5584ed07e
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpServerClient.cs
@@ -0,0 +1,420 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Microsoft.Testing.Platform.ServerMode.Client;
+
+///
+/// High-level client for driving a Microsoft.Testing.Platform (MTP) application over its server-mode
+/// JSON-RPC protocol: initialize handshake, discover, run, run-with-filter, and exit, plus events for
+/// the server-initiated notifications (test-node updates, logs, telemetry, attachments).
+///
+internal interface IMtpServerClient : IDisposable
+{
+ ///
+ /// Raised when the server reports test-node state changes (testing/testUpdates/tests).
+ ///
+ ///
+ /// Ordering guarantee: the client processes the server's messages on a single ordered read loop and
+ /// only completes a discover/run request after the server's terminal response for that request has
+ /// been read. Because the terminal response always follows the node-update notifications on the wire,
+ /// every handler for a given discover/run has already been invoked by the time the corresponding
+ /// /
+ /// task completes. Consumers therefore do not need a settle delay or completion sentinel — awaiting the
+ /// call is sufficient to have collected every node update.
+ /// Event handlers run synchronously on that read loop. They must not perform long-running work or
+ /// synchronously wait on another operation from this client; dispatch such work to another thread.
+ ///
+ event EventHandler? TestNodesUpdated;
+
+ ///
+ /// Raised when the server sends a log message (client/log).
+ ///
+ ///
+ /// Handlers run synchronously on the ordered read loop and must not block or synchronously call back
+ /// into this client.
+ ///
+ event EventHandler? LogReceived;
+
+ ///
+ /// Raised when the server sends a telemetry update (telemetry/update).
+ ///
+ ///
+ /// Handlers run synchronously on the ordered read loop and must not block or synchronously call back
+ /// into this client.
+ ///
+ event EventHandler? TelemetryReceived;
+
+ ///
+ /// Raised when the server reports run attachments (testing/testUpdates/attachments).
+ ///
+ ///
+ /// Handlers run synchronously on the ordered read loop and must not block or synchronously call back
+ /// into this client.
+ ///
+ event EventHandler? AttachmentsReceived;
+
+ ///
+ /// Gets the process id of the launched application, or 0 when the client was created over an
+ /// externally supplied connection (for example in tests).
+ ///
+ int ProcessId { get; }
+
+ ///
+ /// Gets the capabilities negotiated during , or
+ /// before initialize has completed.
+ ///
+ MtpServerCapabilities? Capabilities { get; }
+
+ ///
+ /// Gets or sets an opt-in handler for server-initiated requests (for example the debugger-attach
+ /// request). The handler receives the request method and parameters and returns the response object
+ /// as a dictionary (or to answer with a null result). When the handler itself
+ /// is the client answers every server request with .
+ ///
+ ///
+ /// The result is constrained to because the server-mode
+ /// response is a JSON object; any implementation is accepted and normalized before it is written, so
+ /// the connection can always answer and the server is never left waiting.
+ ///
+ Func?, CancellationToken, Task?>>? ServerRequestHandler { get; set; }
+
+ ///
+ /// Sends the initialize request and returns the negotiated server capabilities.
+ ///
+ Task InitializeAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Discovers every test in the application.
+ ///
+ ///
+ /// When the returned task completes, every handler for this discovery
+ /// has already run (see the event's ordering guarantee).
+ ///
+ Task DiscoverTestsAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Discovers the tests identified by .
+ ///
+ ///
+ /// When the returned task completes, every handler for this discovery
+ /// has already run (see the event's ordering guarantee).
+ ///
+ Task DiscoverTestsAsync(IReadOnlyCollection testNodeUids, CancellationToken cancellationToken = default);
+
+ ///
+ /// Discovers the tests that match the supplied graph filter.
+ ///
+ ///
+ /// When the returned task completes, every handler for this discovery
+ /// has already run (see the event's ordering guarantee).
+ ///
+ Task DiscoverTestsWithFilterAsync(string graphFilter, CancellationToken cancellationToken = default);
+
+ ///
+ /// Runs every test in the application.
+ ///
+ ///
+ /// When the returned task completes, every handler for this run has
+ /// already run (see the event's ordering guarantee).
+ ///
+ Task RunTestsAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Runs the tests identified by .
+ ///
+ ///
+ /// When the returned task completes, every handler for this run has
+ /// already run (see the event's ordering guarantee).
+ ///
+ Task RunTestsAsync(IReadOnlyCollection testNodeUids, CancellationToken cancellationToken = default);
+
+ ///
+ /// Runs the tests that match the supplied graph filter.
+ ///
+ ///
+ /// When the returned task completes, every handler for this run has
+ /// already run (see the event's ordering guarantee).
+ ///
+ Task RunTestsWithFilterAsync(string graphFilter, CancellationToken cancellationToken = default);
+
+ ///
+ /// Sends the exit notification, asking the application to shut down.
+ ///
+ Task ExitAsync(CancellationToken cancellationToken = default);
+}
+
+///
+/// The capabilities the server advertised in its initialize response.
+///
+internal sealed class MtpServerCapabilities
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MtpServerCapabilities(
+ int? serverProcessId,
+ string? serverName,
+ string? serverVersion,
+ bool supportsDiscovery,
+ bool multiRequestSupport,
+ bool vstestProviderSupport,
+ bool supportsAttachments,
+ bool multiConnectionProvider)
+ {
+ ServerProcessId = serverProcessId;
+ ServerName = serverName;
+ ServerVersion = serverVersion;
+ SupportsDiscovery = supportsDiscovery;
+ MultiRequestSupport = multiRequestSupport;
+ VSTestProviderSupport = vstestProviderSupport;
+ SupportsAttachments = supportsAttachments;
+ MultiConnectionProvider = multiConnectionProvider;
+ }
+
+ /// Gets the process id reported by the server.
+ public int? ServerProcessId { get; }
+
+ /// Gets the server name (product identifier).
+ public string? ServerName { get; }
+
+ /// Gets the server version.
+ public string? ServerVersion { get; }
+
+ /// Gets a value indicating whether the server supports discovery.
+ public bool SupportsDiscovery { get; }
+
+ /// Gets a value indicating whether the server supports multiple requests on one connection (keep-alive).
+ public bool MultiRequestSupport { get; }
+
+ /// Gets a value indicating whether the server exposes the VSTest provider.
+ public bool VSTestProviderSupport { get; }
+
+ /// Gets a value indicating whether the server reports attachments.
+ public bool SupportsAttachments { get; }
+
+ /// Gets a value indicating whether the server supports multiple connections.
+ public bool MultiConnectionProvider { get; }
+}
+
+///
+/// The result of a run request: the artifacts (attachments) the server produced.
+///
+internal sealed class MtpRunResult
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MtpRunResult(IReadOnlyList artifacts)
+ => Artifacts = artifacts;
+
+ /// Gets the artifacts produced by the run.
+ public IReadOnlyList Artifacts { get; }
+}
+
+///
+/// A run attachment / artifact reported by the server.
+///
+internal sealed class MtpAttachment
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MtpAttachment(string? uri, string? producer, string? type, string? displayName, string? description)
+ {
+ Uri = uri;
+ Producer = producer;
+ Type = type;
+ DisplayName = displayName;
+ Description = description;
+ }
+
+ /// Gets the attachment URI.
+ public string? Uri { get; }
+
+ /// Gets the producer that emitted the attachment.
+ public string? Producer { get; }
+
+ /// Gets the attachment type.
+ public string? Type { get; }
+
+ /// Gets the display name.
+ public string? DisplayName { get; }
+
+ /// Gets the description.
+ public string? Description { get; }
+}
+
+///
+/// Event args for a batch of test-node state changes.
+///
+internal sealed class MtpTestNodeUpdateEventArgs : EventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MtpTestNodeUpdateEventArgs(Guid runId, IReadOnlyList changes)
+ {
+ RunId = runId;
+ Changes = changes;
+ }
+
+ /// Gets the run id the changes belong to.
+ public Guid RunId { get; }
+
+ /// Gets the reported test-node changes.
+ public IReadOnlyList Changes { get; }
+}
+
+///
+/// A single test-node change. The raw node is exposed as ; the most common fields
+/// are surfaced as convenience accessors. Less common or consumer-specific fields (for example
+/// traits, or the vstest.* bridge properties) stay available on keyed
+/// by their wire name.
+///
+internal sealed class MtpTestNodeUpdate
+{
+ private const string NodeTypeKey = "node-type";
+ private const string ExecutionStateKey = "execution-state";
+ private const string ErrorMessageKey = "error.message";
+ private const string ErrorStackTraceKey = "error.stacktrace";
+ private const string DurationKey = "time.duration-ms";
+ private const string StandardOutputKey = "standardOutput";
+ private const string StandardErrorKey = "standardError";
+ private const string LocationFileKey = "location.file";
+ private const string LocationLineStartKey = "location.line-start";
+ private const string LocationLineEndKey = "location.line-end";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MtpTestNodeUpdate(IDictionary node, string? parentUid)
+ {
+ Node = new Dictionary(node);
+ ParentUid = parentUid;
+ }
+
+ /// Gets the raw node property bag as it arrived on the wire.
+ public IReadOnlyDictionary Node { get; }
+
+ /// Gets the uid of the parent node, when the server supplied one.
+ public string? ParentUid { get; }
+
+ /// Gets the node uid.
+ public string? Uid => GetString(JsonRpcStrings.Uid);
+
+ /// Gets the node display name.
+ public string? DisplayName => GetString(JsonRpcStrings.DisplayName);
+
+ /// Gets the node type (group or action).
+ public string? NodeType => GetString(NodeTypeKey);
+
+ /// Gets the execution state (discovered, in-progress, passed, failed, ...).
+ public string? ExecutionState => GetString(ExecutionStateKey);
+
+ /// Gets the error message, when the node carries one.
+ public string? ErrorMessage => GetString(ErrorMessageKey);
+
+ /// Gets the error stack trace, when the node carries one.
+ public string? ErrorStackTrace => GetString(ErrorStackTraceKey);
+
+ /// Gets the reported duration in milliseconds, when the node carries one.
+ public double? DurationInMilliseconds => Node.TryGetValue(DurationKey, out object? value)
+ ? value switch
+ {
+ double d => d,
+ float f => f,
+ int i => i,
+ long l => l,
+ decimal m => (double)m,
+ _ => null,
+ }
+ : null;
+
+ /// Gets the captured standard output (wire standardOutput), when the node carries one.
+ public string? StandardOutput => GetString(StandardOutputKey);
+
+ /// Gets the captured standard error (wire standardError), when the node carries one.
+ public string? StandardError => GetString(StandardErrorKey);
+
+ /// Gets the source file path of the node (wire location.file), when the server reported a location.
+ public string? FilePath => GetString(LocationFileKey);
+
+ /// Gets the start line of the node's source location (wire location.line-start), when reported.
+ public int? LineStart => GetInt32(LocationLineStartKey);
+
+ /// Gets the end line of the node's source location (wire location.line-end), when reported.
+ public int? LineEnd => GetInt32(LocationLineEndKey);
+
+ private string? GetString(string key)
+ => Node.TryGetValue(key, out object? value) ? value as string : null;
+
+ private int? GetInt32(string key) => Node.TryGetValue(key, out object? value)
+ ? value switch
+ {
+ int i => i,
+ long l => (int)l,
+ short s => s,
+ double d => (int)d,
+ float f => (int)f,
+ decimal m => (int)m,
+ _ => null,
+ }
+ : null;
+}
+
+///
+/// Event args for a server log message.
+///
+internal sealed class MtpLogEventArgs : EventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MtpLogEventArgs(string level, string message)
+ {
+ Level = level;
+ Message = message;
+ }
+
+ /// Gets the log level string as reported by the server.
+ public string Level { get; }
+
+ /// Gets the log message.
+ public string Message { get; }
+}
+
+///
+/// Event args for a telemetry update.
+///
+internal sealed class MtpTelemetryEventArgs : EventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MtpTelemetryEventArgs(string eventName, IReadOnlyDictionary metrics)
+ {
+ EventName = eventName;
+ Metrics = metrics;
+ }
+
+ /// Gets the telemetry event name.
+ public string EventName { get; }
+
+ /// Gets the telemetry metrics.
+ public IReadOnlyDictionary Metrics { get; }
+}
+
+///
+/// Event args for a batch of run attachments.
+///
+internal sealed class MtpAttachmentsEventArgs : EventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MtpAttachmentsEventArgs(IReadOnlyList attachments)
+ => Attachments = attachments;
+
+ /// Gets the reported attachments.
+ public IReadOnlyList Attachments { get; }
+}
diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpClientModernPolyfills.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpClientModernPolyfills.cs
new file mode 100644
index 0000000000..3a44590f68
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpClientModernPolyfills.cs
@@ -0,0 +1,78 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+#if NET5_0 || NET6_0
+namespace System.Runtime.CompilerServices
+{
+#if NET5_0
+ [AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
+ internal sealed class CallerArgumentExpressionAttribute(string parameterName) : Attribute
+ {
+ public string ParameterName { get; } = parameterName;
+ }
+#endif
+
+ [AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)]
+ internal sealed class CompilerFeatureRequiredAttribute(string featureName) : Attribute
+ {
+ public const string RequiredMembers = nameof(RequiredMembers);
+
+ public string FeatureName { get; } = featureName;
+
+ public bool IsOptional { get; init; }
+ }
+
+ [AttributeUsage(
+ AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Field | AttributeTargets.Property,
+ AllowMultiple = false,
+ Inherited = false)]
+ internal sealed class RequiredMemberAttribute : Attribute;
+}
+
+namespace System.Diagnostics
+{
+ internal sealed class UnreachableException : Exception
+ {
+ public UnreachableException()
+ : base("The program executed an instruction that was thought to be unreachable.")
+ {
+ }
+
+ public UnreachableException(string? message)
+ : base(message)
+ {
+ }
+
+ public UnreachableException(string? message, Exception? innerException)
+ : base(message, innerException)
+ {
+ }
+ }
+}
+#endif
+
+#if NET5_0 || NET6_0 || NET7_0
+namespace System.Diagnostics.CodeAnalysis
+{
+ [AttributeUsage(
+ AttributeTargets.Assembly
+ | AttributeTargets.Module
+ | AttributeTargets.Class
+ | AttributeTargets.Struct
+ | AttributeTargets.Enum
+ | AttributeTargets.Constructor
+ | AttributeTargets.Method
+ | AttributeTargets.Property
+ | AttributeTargets.Field
+ | AttributeTargets.Event
+ | AttributeTargets.Interface
+ | AttributeTargets.Delegate,
+ Inherited = false)]
+ internal sealed class ExperimentalAttribute(string diagnosticId) : Attribute
+ {
+ public string DiagnosticId { get; } = diagnosticId;
+
+ public string? UrlFormat { get; set; }
+ }
+}
+#endif
diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpClientOperatingSystem.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpClientOperatingSystem.cs
new file mode 100644
index 0000000000..49a53ecb6b
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpClientOperatingSystem.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Microsoft.Testing.Platform.ServerMode.Client;
+
+internal static class MtpClientOperatingSystem
+{
+ public static bool IsBrowser()
+#if NETFRAMEWORK
+ => false;
+#else
+ => RuntimeInformation.IsOSPlatform(OSPlatform.Create("BROWSER"));
+#endif
+}
diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpJsonRpcConnection.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpJsonRpcConnection.cs
new file mode 100644
index 0000000000..f60c5206b4
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpJsonRpcConnection.cs
@@ -0,0 +1,371 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Microsoft.Testing.Platform.ServerMode.Client;
+
+///
+/// The JSON-RPC transport engine for an MTP server-mode connection. It owns an
+/// (the reused TcpMessageHandler in production), runs a background read loop, correlates responses to
+/// their requests, and surfaces server-initiated notifications and requests.
+///
+///
+/// This type is transport-only: it knows nothing about initialize/discover/run
+/// semantics. The higher-level MtpServerClient builds its typed API on top of
+/// , , and .
+/// Because the connection sits above , tests can drive it over a real loopback
+/// socket without any additional seams.
+///
+internal sealed class MtpJsonRpcConnection : IDisposable
+{
+ private readonly IMessageHandler _handler;
+ private readonly IMtpClientLogger _logger;
+ private readonly ConcurrentDictionary _pendingRequests = new();
+ private readonly SemaphoreSlim _writeLock = new(1, 1);
+ private readonly CancellationTokenSource _readLoopCancellation = new();
+ private readonly object _startLock = new();
+
+ // True within the read loop's async execution flow. Dispose reads this to detect a re-entrant call
+ // from a notification / server-request handler (both dispatched on the read-loop flow) and skip
+ // synchronously waiting on the read loop from within itself. This uses AsyncLocal rather than
+ // Task.CurrentId because the read loop is an async method: after its first await the continuation no
+ // longer reports the Task.Run task's id, so Task.CurrentId would spuriously not match and Dispose
+ // would self-wait for the full shutdown timeout. AsyncLocal rides the flow across every await.
+ private readonly AsyncLocal _onReadLoopFlow = new();
+
+ // Bounded wait for the read loop to observe cancellation / socket close during Dispose.
+ private static readonly TimeSpan ReadLoopShutdownTimeout = TimeSpan.FromSeconds(5);
+
+ private int _nextRequestId;
+ private Task? _readLoop;
+ private Func>? _serverRequestHandler;
+ private int _disposed;
+
+ // Latched once when the connection reaches a terminal state (read loop exited or Dispose ran). A
+ // non-null value means no read loop remains to complete a response, so new sends must fail fast.
+ private Exception? _closedReason;
+
+ public MtpJsonRpcConnection(IMessageHandler handler, IMtpClientLogger? logger = null)
+ {
+ _handler = handler ?? throw new ArgumentNullException(nameof(handler));
+ _logger = logger ?? NullMtpClientLogger.Instance;
+ }
+
+ ///
+ /// Raised for every server-to-client notification. The handler receives the method name and the raw
+ /// params payload (an IDictionary<string, object?> or ); the client API
+ /// layer decodes it based on the method.
+ ///
+ ///
+ /// Handlers run synchronously on the single read-loop thread that also drains the socket, so
+ /// notifications are delivered strictly in the order the server sent them (the ordering guarantee the
+ /// client API relies on). A handler MUST NOT block for a long time or synchronously wait on another
+ /// request to this client (for example RunTestsAsync(...).GetAwaiter().GetResult()): doing so
+ /// stalls the read loop and, for a re-entrant client call, deadlocks because the response can never be
+ /// read. Marshal to another thread if the handler needs to do slow work or call back into the client.
+ ///
+ public event Action? NotificationReceived;
+
+ ///
+ /// Gets or sets the handler for server-initiated requests (for example client/attachDebugger).
+ /// The delegate returns the result object used to answer the request; returning
+ /// answers with a null result. The connection ALWAYS sends a response so the server never blocks — if no
+ /// handler is set, or the handler throws, a null-result response is sent.
+ ///
+ public Func>? ServerRequestHandler
+ {
+ get => Volatile.Read(ref _serverRequestHandler);
+ set => Volatile.Write(ref _serverRequestHandler, value);
+ }
+
+ ///
+ /// Starts the background read loop. Call once, after wiring and
+ /// .
+ ///
+ public void Start()
+ {
+ lock (_startLock)
+ {
+ if (Volatile.Read(ref _disposed) != 0)
+ {
+ throw new ObjectDisposedException(nameof(MtpJsonRpcConnection));
+ }
+
+ _readLoop ??= Task.Run(() => ReadLoopAsync(_readLoopCancellation.Token));
+ }
+ }
+
+ ///
+ /// Sends a request and awaits its correlated response. If fires
+ /// before the response arrives, a $/cancelRequest notification is sent to the server and the
+ /// returned task is canceled.
+ ///
+ public async Task SendRequestAsync(string method, object? @params, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Fail fast if the connection has already reached a terminal state: no read loop remains to
+ // complete a response, so registering the request would hang forever (a TCP write after the peer's
+ // FIN can still land in the send buffer and succeed, so WriteMessageAsync would not surface the
+ // closure).
+ if (Volatile.Read(ref _closedReason) is { } closedBefore)
+ {
+ throw closedBefore;
+ }
+
+ int id = Interlocked.Increment(ref _nextRequestId);
+ var pending = new PendingRequest(method);
+ _pendingRequests[id] = pending;
+
+ // Re-check after registering: the read loop may have latched a terminal reason and run
+ // FailAllPending between the check above and this insert, missing this entry. Observing the reason
+ // here guarantees the request is completed rather than left waiting.
+ if (Volatile.Read(ref _closedReason) is { } closedAfter)
+ {
+ _pendingRequests.TryRemove(id, out _);
+ throw closedAfter;
+ }
+
+ using CancellationTokenRegistration registration = cancellationToken.Register(
+ () => CancelPendingRequest(id, cancellationToken));
+
+ try
+ {
+ await WriteMessageAsync(new RequestMessage(id, method, @params), cancellationToken).ConfigureAwait(false);
+ return await pending.Completion.Task.ConfigureAwait(false);
+ }
+ finally
+ {
+ _pendingRequests.TryRemove(id, out _);
+ }
+ }
+
+ ///
+ /// Sends a fire-and-forget notification to the server.
+ ///
+ public Task SendNotificationAsync(string method, object? @params, CancellationToken cancellationToken)
+ => WriteMessageAsync(new NotificationMessage(method, @params), cancellationToken);
+
+ private async Task WriteMessageAsync(RpcMessage message, CancellationToken cancellationToken)
+ {
+ await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ // Acquiring the write lock is cancellable, but the frame write itself is NOT: once a
+ // Content-Length frame starts going out, cancelling mid-write would leave a partial frame on
+ // the wire and the very next write (for example a $/cancelRequest) would desync the server's
+ // framing. Pass CancellationToken.None so a started frame always completes atomically.
+ await _handler.WriteRequestAsync(message, CancellationToken.None).ConfigureAwait(false);
+ }
+ finally
+ {
+ _writeLock.Release();
+ }
+ }
+
+ private async Task ReadLoopAsync(CancellationToken cancellationToken)
+ {
+ // Mark this async flow as the read loop so a handler that calls Dispose (which runs on this flow)
+ // is detected re-entrantly and does not synchronously wait on the loop from within it. Set before
+ // the first await so the marker flows across every await and into every synchronous Dispatch.
+ _onReadLoopFlow.Value = true;
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ RpcMessage? message = await _handler.ReadAsync(cancellationToken).ConfigureAwait(false);
+ if (message is null)
+ {
+ // Null signals a graceful or abrupt disconnect.
+ Close(new MtpServerConnectionClosedException());
+ return;
+ }
+
+ Dispatch(message, cancellationToken);
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Expected during teardown.
+ }
+ catch (Exception ex)
+ {
+ // Fail pending requests FIRST (via Close, which also latches the terminal state): a caller
+ // awaiting a response must be released even if logging throws. SafeLog additionally guarantees
+ // the logger cannot fault this loop.
+ Close(new MtpServerClientException("The MTP client read loop failed.", ex));
+ _logger.SafeLog(MtpClientLogLevel.Error, $"MTP client read loop failed: {ex}");
+ }
+ }
+
+ private void Dispatch(RpcMessage message, CancellationToken cancellationToken)
+ {
+ switch (message)
+ {
+ case ResponseMessage response:
+ if (_pendingRequests.TryGetValue(response.Id, out PendingRequest? successful))
+ {
+ successful.Completion.TrySetResult(response);
+ }
+
+ break;
+
+ case ErrorMessage error:
+ if (_pendingRequests.TryGetValue(error.Id, out PendingRequest? failed))
+ {
+ failed.Completion.TrySetException(new MtpServerErrorException(error.ErrorCode, error.Message));
+ }
+
+ break;
+
+ case NotificationMessage notification:
+ RaiseNotification(notification);
+ break;
+
+ case RequestMessage request:
+ _ = HandleServerRequestAsync(request, cancellationToken);
+ break;
+ }
+ }
+
+ private void RaiseNotification(NotificationMessage notification)
+ {
+ try
+ {
+ NotificationReceived?.Invoke(notification);
+ }
+ catch (Exception ex)
+ {
+ _logger.SafeLog(MtpClientLogLevel.Warning, $"A handler for notification '{notification.Method}' threw: {ex}");
+ }
+ }
+
+ private async Task HandleServerRequestAsync(RequestMessage request, CancellationToken cancellationToken)
+ {
+ object? result = null;
+ try
+ {
+ Func>? handler = Volatile.Read(ref _serverRequestHandler);
+ if (handler is not null)
+ {
+ result = await handler(request, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.SafeLog(MtpClientLogLevel.Warning, $"The handler for server request '{request.Method}' threw: {ex}");
+ }
+
+ // Always answer so the server is never left waiting.
+ try
+ {
+ await WriteMessageAsync(new ResponseMessage(request.Id, result), cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.SafeLog(MtpClientLogLevel.Warning, $"Failed to respond to server request '{request.Method}': {ex}");
+ }
+ }
+
+ private void CancelPendingRequest(int id, CancellationToken cancellationToken)
+ {
+ if (!_pendingRequests.TryGetValue(id, out PendingRequest? pending))
+ {
+ return;
+ }
+
+ pending.Completion.TrySetCanceled(cancellationToken);
+
+ // Best-effort notify the server to stop the in-flight work.
+ _ = SendCancelNotificationAsync(id);
+ }
+
+ private async Task SendCancelNotificationAsync(int id)
+ {
+ try
+ {
+ await SendNotificationAsync(JsonRpcMethods.CancelRequest, new CancelRequestArgs(id), CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.SafeLog(MtpClientLogLevel.Debug, $"Failed to send $/cancelRequest for request {id}: {ex}");
+ }
+ }
+
+ private void Close(Exception reason)
+ {
+ // Latch the terminal reason exactly once, THEN fail every pending request. The ordering is the
+ // crux of the race fix with SendRequestAsync: a sender registers its pending request and then
+ // re-reads _closedReason, so either this FailAllPending observes that request, or the sender
+ // observes the latched reason — the request can never be left hanging with no one to complete it.
+ if (Interlocked.CompareExchange(ref _closedReason, reason, null) is null)
+ {
+ FailAllPending(reason);
+ }
+ }
+
+ private void FailAllPending(Exception exception)
+ {
+ foreach (KeyValuePair entry in _pendingRequests)
+ {
+ if (_pendingRequests.TryRemove(entry.Key, out PendingRequest? pending))
+ {
+ pending.Completion.TrySetException(exception);
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ Task? readLoop;
+ lock (_startLock)
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) == 1)
+ {
+ return;
+ }
+
+ readLoop = _readLoop;
+ }
+
+ // Latch the terminal state and release anyone awaiting a response.
+ Close(new ObjectDisposedException(nameof(MtpJsonRpcConnection)));
+
+ _readLoopCancellation.Cancel();
+
+ // Disposing the handler closes the underlying socket/streams, which unblocks a read loop parked in
+ // a blocking ReadAsync that cancellation alone would not interrupt.
+ try
+ {
+ (_handler as IDisposable)?.Dispose();
+ }
+ catch (Exception ex)
+ {
+ _logger.SafeLog(MtpClientLogLevel.Debug, $"Disposing the message handler threw: {ex}");
+ }
+
+ // Wait (bounded) for the read loop to actually finish, then intentionally do NOT dispose
+ // _readLoopCancellation / _writeLock: leaking two lightweight primitives is strictly better than
+ // disposing them out from under an in-flight write or the read loop's ReadAsync, which would
+ // surface spurious ObjectDisposedExceptions (one thrown out of the write lock's finally block).
+ // Guard against waiting on ourselves in case Dispose runs from a notification / server-request
+ // handler executing on the read-loop flow (see _onReadLoopFlow).
+ if (readLoop is not null && !_onReadLoopFlow.Value)
+ {
+ try
+ {
+ readLoop.Wait(ReadLoopShutdownTimeout);
+ }
+ catch (Exception ex)
+ {
+ _logger.SafeLog(MtpClientLogLevel.Debug, $"Waiting for the read loop to stop threw: {ex}");
+ }
+ }
+ }
+
+ private sealed class PendingRequest(string method)
+ {
+ public string Method { get; } = method;
+
+ public TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ }
+}
diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClient.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClient.cs
new file mode 100644
index 0000000000..bab20eb106
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClient.cs
@@ -0,0 +1,448 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.Testing.Platform.Extensions.Messages;
+
+namespace Microsoft.Testing.Platform.ServerMode.Client;
+
+///
+/// Default implementation over a .
+///
+///
+/// Two ways to obtain a client:
+///
+/// starts the MTP application and owns its process.
+/// The constructor wraps an
+/// already-connected transport (used by tests over a paired in-memory stream).
+///
+/// The constructor attaches the notification and server-request handlers. The connection read loop starts
+/// lazily on the first client operation, giving callers time to subscribe to events first.
+///
+internal sealed class MtpServerClient : IMtpServerClient
+{
+ private readonly MtpJsonRpcConnection _connection;
+ private readonly MtpServerClientOptions _options;
+ private readonly MtpServerProcess? _process;
+
+ private Func?, CancellationToken, Task?>>? _serverRequestHandler;
+ private int _disposed;
+
+ ///
+ /// Initializes a new instance of the class over an existing connection.
+ ///
+ /// The transport connection. Its read loop starts on the first client operation.
+ /// Client options (name, capabilities, logger). Defaults are used when omitted.
+ ///
+ /// Precondition: the connection's formatter must have been created with the client serializers already
+ /// registered — call before building the
+ /// formatter passed to . The factory does this for you;
+ /// callers that construct a connection directly are responsible for the ordering.
+ ///
+ public MtpServerClient(MtpJsonRpcConnection connection, MtpServerClientOptions? options = null)
+ {
+ _connection = connection ?? throw new ArgumentNullException(nameof(connection));
+ _options = options ?? new MtpServerClientOptions();
+
+ _connection.NotificationReceived += OnNotificationReceived;
+ _connection.ServerRequestHandler = OnServerRequestAsync;
+ }
+
+ private MtpServerClient(MtpServerProcess process, MtpServerClientOptions options)
+ : this(process.Connection, options)
+ => _process = process;
+
+ ///
+ public event EventHandler? TestNodesUpdated;
+
+ ///
+ public event EventHandler? LogReceived;
+
+ ///
+ public event EventHandler? TelemetryReceived;
+
+ ///
+ public event EventHandler? AttachmentsReceived;
+
+ ///
+ public Func?, CancellationToken, Task?>>? ServerRequestHandler
+ {
+ get => Volatile.Read(ref _serverRequestHandler);
+ set => Volatile.Write(ref _serverRequestHandler, value);
+ }
+
+ ///
+ public int ProcessId => _process?.ProcessId ?? 0;
+
+ ///
+ public MtpServerCapabilities? Capabilities { get; private set; }
+
+ ///
+ /// Launches the MTP application at in server mode and returns a connected client.
+ ///
+ /// Path to the test application (managed .dll or native .exe).
+ /// Client options (name, capabilities, connection timeout, environment, logger).
+ public static MtpServerClient Launch(string source, MtpServerClientOptions? options = null)
+ => LaunchAsync(source, options, CancellationToken.None).GetAwaiter().GetResult();
+
+ ///
+ /// Launches the MTP application at in server mode and asynchronously waits for
+ /// it to connect.
+ ///
+ /// Path to the test application (managed .dll or native .exe).
+ /// Client options (name, capabilities, connection timeout, environment, logger).
+ /// Cancels the launch and connection wait.
+ public static async Task LaunchAsync(
+ string source,
+ MtpServerClientOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (source is null)
+ {
+ throw new ArgumentNullException(nameof(source));
+ }
+
+ options ??= new MtpServerClientOptions();
+ MtpServerProcess process = await MtpServerProcess.StartAsync(source, options, cancellationToken).ConfigureAwait(false);
+ try
+ {
+ return new MtpServerClient(process, options);
+ }
+ catch
+ {
+ process.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ public async Task InitializeAsync(CancellationToken cancellationToken = default)
+ {
+ EnsureStarted();
+ var args = new InitializeRequestArgs(
+ GetCurrentProcessId(),
+ new ClientInfo(_options.ClientName, _options.ClientVersion),
+ new ClientCapabilities(_options.DebuggerProvider, _options.IsStateful));
+
+ ResponseMessage response = await _connection.SendRequestAsync(JsonRpcMethods.Initialize, args, cancellationToken).ConfigureAwait(false);
+ MtpServerCapabilities capabilities = DecodeCapabilities(AsResultDictionary(response.Result));
+ Capabilities = capabilities;
+ return capabilities;
+ }
+
+ ///
+ public Task DiscoverTestsAsync(CancellationToken cancellationToken = default)
+ => DiscoverCoreAsync(null, null, cancellationToken);
+
+ ///
+ public Task DiscoverTestsAsync(IReadOnlyCollection testNodeUids, CancellationToken cancellationToken = default)
+ => DiscoverCoreAsync(BuildTestNodes(testNodeUids ?? throw new ArgumentNullException(nameof(testNodeUids))), null, cancellationToken);
+
+ ///
+ public Task DiscoverTestsWithFilterAsync(string graphFilter, CancellationToken cancellationToken = default)
+ => DiscoverCoreAsync(null, graphFilter ?? throw new ArgumentNullException(nameof(graphFilter)), cancellationToken);
+
+ ///
+ public Task RunTestsAsync(CancellationToken cancellationToken = default)
+ => RunCoreAsync(null, null, cancellationToken);
+
+ ///
+ public Task RunTestsAsync(IReadOnlyCollection testNodeUids, CancellationToken cancellationToken = default)
+ => RunCoreAsync(BuildTestNodes(testNodeUids ?? throw new ArgumentNullException(nameof(testNodeUids))), null, cancellationToken);
+
+ ///
+ public Task RunTestsWithFilterAsync(string graphFilter, CancellationToken cancellationToken = default)
+ => RunCoreAsync(null, graphFilter ?? throw new ArgumentNullException(nameof(graphFilter)), cancellationToken);
+
+ ///
+ public Task ExitAsync(CancellationToken cancellationToken = default)
+ {
+ EnsureStarted();
+ return _connection.SendNotificationAsync(JsonRpcMethods.Exit, null, cancellationToken);
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) != 0)
+ {
+ return;
+ }
+
+ _connection.NotificationReceived -= OnNotificationReceived;
+ _connection.ServerRequestHandler = null;
+
+ if (_process is not null)
+ {
+ _process.Dispose();
+ }
+ else
+ {
+ _connection.Dispose();
+ }
+ }
+
+ private static int GetCurrentProcessId()
+ {
+ using var current = Process.GetCurrentProcess();
+ return current.Id;
+ }
+
+ private static ICollection BuildTestNodes(IReadOnlyCollection testNodeUids)
+ => testNodeUids.Select(uid => new TestNode { Uid = uid, DisplayName = uid }).ToList();
+
+ private static MtpServerCapabilities DecodeCapabilities(IDictionary? result)
+ {
+ result ??= new Dictionary();
+
+ int? processId = result.TryGetValue(JsonRpcStrings.ProcessId, out object? processIdObj) ? AsInt(processIdObj) : null;
+
+ string? serverName = null;
+ string? serverVersion = null;
+ if (result.TryGetValue(JsonRpcStrings.ServerInfo, out object? serverInfoObj)
+ && serverInfoObj is IDictionary serverInfo)
+ {
+ serverName = serverInfo.TryGetValue(JsonRpcStrings.Name, out object? nameObj) ? nameObj as string : null;
+ serverVersion = serverInfo.TryGetValue(JsonRpcStrings.Version, out object? versionObj) ? versionObj as string : null;
+ }
+
+ bool supportsDiscovery = false;
+ bool multiRequestSupport = false;
+ bool vstestProviderSupport = false;
+ bool supportsAttachments = false;
+ bool multiConnectionProvider = false;
+ if (result.TryGetValue(JsonRpcStrings.Capabilities, out object? capabilitiesObj)
+ && capabilitiesObj is IDictionary capabilities
+ && capabilities.TryGetValue(JsonRpcStrings.Testing, out object? testingObj)
+ && testingObj is IDictionary testing)
+ {
+ supportsDiscovery = AsBool(testing, JsonRpcStrings.SupportsDiscovery);
+ multiRequestSupport = AsBool(testing, JsonRpcStrings.MultiRequestSupport);
+ vstestProviderSupport = AsBool(testing, JsonRpcStrings.VSTestProviderSupport);
+ supportsAttachments = AsBool(testing, JsonRpcStrings.AttachmentsSupport);
+ multiConnectionProvider = AsBool(testing, JsonRpcStrings.MultiConnectionProvider);
+ }
+
+ return new MtpServerCapabilities(
+ processId,
+ serverName,
+ serverVersion,
+ supportsDiscovery,
+ multiRequestSupport,
+ vstestProviderSupport,
+ supportsAttachments,
+ multiConnectionProvider);
+ }
+
+ private static int? AsInt(object? value)
+ => value switch
+ {
+ int i => i,
+ short s => s,
+ byte b => b,
+
+ // The JSON formatter may widen an integer to long/ulong/double depending on its magnitude, so
+ // accept those too but only when the value round-trips into an Int32 without loss. Anything out
+ // of range or non-integral is treated as absent (null) rather than silently truncated.
+ long l when l is >= int.MinValue and <= int.MaxValue => (int)l,
+ uint u when u <= int.MaxValue => (int)u,
+ ulong ul when ul <= int.MaxValue => (int)ul,
+
+ // `d % 1d is 0d` is the integrality test (behaviorally equal to `d == Math.Floor(d)`) written as
+ // a constant pattern so it does not trip the analyzer's "equality on floating-point" rule; the
+ // remainder of an in-range integral double against 1 is exactly zero.
+ double d when d is >= int.MinValue and <= int.MaxValue && d % 1d is 0d => (int)d,
+ _ => null,
+ };
+
+ private static bool AsBool(IDictionary dictionary, string key)
+ => dictionary.TryGetValue(key, out object? value) && value is bool boolean && boolean;
+
+ // A null result is tolerated (the server answered with no payload -> decode defaults/empty). A non-null
+ // result that is not the expected IDictionary is a protocol violation, so surface it
+ // with the actual runtime type instead of silently discarding it via `as` (which would look like an
+ // empty/absent result and hide the mismatch).
+ private static IDictionary? AsResultDictionary(object? result)
+ => result switch
+ {
+ null => null,
+ IDictionary dictionary => dictionary,
+ _ => throw new MtpServerClientException(
+ $"Expected the server response result to be an IDictionary but it was '{result.GetType()}'."),
+ };
+
+ private async Task DiscoverCoreAsync(ICollection? tests, string? graphFilter, CancellationToken cancellationToken)
+ {
+ EnsureStarted();
+ var args = new DiscoverRequestArgs(Guid.NewGuid(), tests, graphFilter);
+ await _connection.SendRequestAsync(JsonRpcMethods.TestingDiscoverTests, args, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task RunCoreAsync(ICollection? tests, string? graphFilter, CancellationToken cancellationToken)
+ {
+ EnsureStarted();
+ var args = new RunRequestArgs(Guid.NewGuid(), tests, graphFilter);
+ ResponseMessage response = await _connection.SendRequestAsync(JsonRpcMethods.TestingRunTests, args, cancellationToken).ConfigureAwait(false);
+
+ IDictionary resultDict = AsResultDictionary(response.Result) ?? new Dictionary();
+ RunResponseArgs runResponse = SerializerUtilities.Deserialize(resultDict);
+ MtpAttachment[] artifacts = runResponse.Artifacts
+ .Select(artifact => new MtpAttachment(artifact.Uri, artifact.Producer, artifact.Type, artifact.DisplayName, artifact.Description))
+ .ToArray();
+
+ return new MtpRunResult(artifacts);
+ }
+
+ private async Task