diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs
new file mode 100644
index 00000000..a6afb152
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs
@@ -0,0 +1,200 @@
+using System.Diagnostics;
+using System.Net.Http;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// One-shot MTConnect Agent reachability probe for the AdminUI's Test Connect button. Issues a
+/// single GET {AgentUri}/probe under the supplied deadline and reports success/failure
+/// with latency — it does not construct or initialize an instance.
+///
+///
+///
+/// Deliberately does NOT delegate to . That
+/// method builds the full driver options document, including every authored
+/// MTConnectTagDefinition (BuildTag can throw on a malformed tag entry that has
+/// nothing to do with reachability). A probe only needs agentUri, so this type parses
+/// its own minimal DTO — a config that is invalid for the driver (bad tag, bad data type) can
+/// still be reachability-tested, matching the AdminUI's "does the endpoint answer" intent for
+/// Test Connect. It also avoids a compile-time dependency on the factory DTO shape
+/// (MTConnectDriverFactoryExtensions), which is authored separately (Task 15).
+///
+///
+/// Deliberately DOES reuse and
+/// for the actual round trip. Hand-rolling the HTTP
+/// call would have to re-derive the exact behaviour the client already provides: per-call
+/// bounding, a linked-CTS deadline, and — most importantly —
+/// correct handling of an Agent's MTConnectError document served under HTTP 200 (so
+/// EnsureSuccessStatusCode never fires). already
+/// lifts the Agent's own error text into the thrown exception's message; re-parsing by hand
+/// here would either duplicate that logic or under-validate (accepting any 200 response as
+/// healthy, which is precisely the "empty-but-successful" defect class this codebase has hit
+/// before, #485). The one-off cost of a full probe-document parse is negligible next to that
+/// risk.
+///
+///
+/// Never throws (the contract): every failure — unparseable
+/// JSON, missing/malformed agentUri, DNS/TCP failure, a non-2xx response, a 200 body
+/// that is not a valid MTConnectDevices document, and timeout — becomes
+/// Ok = false with a message. Genuine caller cancellation is likewise converted rather
+/// than left to propagate, matching ModbusDriverProbe's posture (its linked-CTS catch
+/// does not distinguish the caller's token from its own deadline).
+///
+///
+/// Non-positive timeout (arch-review 01/S-6). A 0 or negative
+/// timeout does NOT mean "wait forever" — it is replaced with
+/// (5s, matching 's own default) so an
+/// operator-authorable field can never brick the probe UI.
+///
+///
+/// Secrets discipline. AgentUri may carry userinfo credentials
+/// (http://user:pass@host/). Every message this type constructs from the URI uses a
+/// redacted display form () built ONLY from
+/// ///
+/// — never and never the raw config string — so a password can
+/// never reach the AdminUI via the returned .
+///
+///
+public sealed class MTConnectDriverProbe : IDriverProbe
+{
+ /// Replaces a non-positive caller timeout — see the type remarks.
+ private static readonly TimeSpan FallbackTimeout = TimeSpan.FromSeconds(5);
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ /// Test seam: routes the reused through a stub handler so response-shape cases need no socket.
+ private readonly HttpMessageHandler? _handler;
+
+ /// Creates the probe used in production (real sockets).
+ public MTConnectDriverProbe()
+ : this(handler: null)
+ {
+ }
+
+ /// Test seam constructor — see .
+ internal MTConnectDriverProbe(HttpMessageHandler? handler)
+ {
+ _handler = handler;
+ }
+
+ ///
+ public string DriverType => "MTConnect";
+
+ /// Minimal probe-only config shape. Deliberately independent of the factory DTO — see the type remarks.
+ private sealed class MTConnectProbeConfigDto
+ {
+ public string? AgentUri { get; set; }
+ }
+
+ ///
+ public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
+ {
+ MTConnectProbeConfigDto? dto;
+ try
+ {
+ dto = JsonSerializer.Deserialize(configJson, JsonOptions);
+ }
+ catch (Exception ex)
+ {
+ return new(false, $"Config JSON is invalid: {ex.Message}", null);
+ }
+
+ if (dto is null)
+ {
+ return new(false, "Config JSON deserialized to null.", null);
+ }
+
+ var agentUri = dto.AgentUri?.Trim();
+ if (string.IsNullOrWhiteSpace(agentUri))
+ {
+ return new(false, "Config has no agentUri to probe.", null);
+ }
+
+ if (!Uri.TryCreate(agentUri, UriKind.Absolute, out var parsed) ||
+ (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
+ {
+ return new(false, "Config's agentUri is not an absolute http(s) URI.", null);
+ }
+
+ var safeUri = RedactedDisplay(parsed);
+
+ // arch-review 01/S-6: a non-positive timeout must never mean "wait forever".
+ var effectiveTimeout = timeout > TimeSpan.Zero ? timeout : FallbackTimeout;
+ var requestTimeoutMs = Math.Max(1, (int)Math.Ceiling(effectiveTimeout.TotalMilliseconds));
+
+ var sw = Stopwatch.StartNew();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(effectiveTimeout);
+
+ var options = new MTConnectDriverOptions
+ {
+ AgentUri = agentUri,
+ RequestTimeoutMs = requestTimeoutMs,
+ };
+
+ try
+ {
+ using var client = new MTConnectAgentClient(options, _handler);
+ _ = await client.ProbeAsync(cts.Token).ConfigureAwait(false);
+ sw.Stop();
+
+ return new(true, $"MTConnect /probe OK ({safeUri})", sw.Elapsed);
+ }
+ catch (OperationCanceledException)
+ {
+ // Covers both the caller's token and our own CancelAfter deadline firing — see the type
+ // remarks on why cancellation is never allowed to propagate.
+ return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null);
+ }
+ catch (TimeoutException)
+ {
+ // MTConnectAgentClient's own request-deadline signal.
+ return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null);
+ }
+ catch (HttpRequestException ex)
+ {
+ // Connection failure or non-2xx status. HttpRequestException messages describe the
+ // socket endpoint or status code, never the request URI/userinfo, so ex.Message is safe.
+ return new(false, $"Request to {safeUri} failed: {ex.Message}", null);
+ }
+ catch (InvalidDataException ex)
+ {
+ // MTConnectProbeParser: not well-formed XML, not an MTConnectDevices document (including
+ // an MTConnectError-under-200, whose own errorCode/text ex.Message already carries), or
+ // a document with no devices. Never includes the request URI.
+ return new(false, $"Agent at {safeUri} did not return a valid MTConnect device model: {ex.Message}", null);
+ }
+ catch (ArgumentException)
+ {
+ // Defensive: MTConnectAgentClient's own AgentUri validation, reached only if this
+ // method's own Uri.TryCreate above passed but the client's stricter check disagreed.
+ // Never forward the exception's message verbatim — it can echo the raw (possibly
+ // credentialed) config string.
+ return new(false, "Config's agentUri was rejected by the MTConnect client as invalid.", null);
+ }
+ catch (Exception ex)
+ {
+ return new(false, ex.Message, null);
+ }
+ }
+
+ ///
+ /// Builds a display form of for use in returned messages, composed
+ /// ONLY from scheme/host/port/path — never — so a credentialed
+ /// AgentUri can never leak a password into the AdminUI.
+ ///
+ private static string RedactedDisplay(Uri uri)
+ {
+ var portSegment = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
+
+ return $"{uri.Scheme}://{uri.Host}{portSegment}{uri.AbsolutePath}";
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverProbeTests.cs
new file mode 100644
index 00000000..8e25ba29
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverProbeTests.cs
@@ -0,0 +1,235 @@
+using System.Net;
+using System.Net.Http;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Task 14 — : the AdminUI Test Connect reachability probe.
+/// Response-shape cases (non-MTConnect body, MTConnectError-under-200, a valid device model) are
+/// exercised through a stubbed via the internal test-seam
+/// constructor, so no socket is needed; the unreachable-agent and non-positive-timeout cases use
+/// a real (deliberately unroutable/refused) TCP target, mirroring the plan's canned example.
+///
+[Trait("Category", "Unit")]
+public sealed class MTConnectDriverProbeTests
+{
+ private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3);
+
+ // ── stub handler ─────────────────────────────────────────────────────────
+
+ /// Answers every request with a canned response (or throws) via a delegate, with no socket.
+ private sealed class StubHandler(Func> respond)
+ : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) =>
+ respond(request, ct);
+ }
+
+ private static HttpResponseMessage XmlResponse(string xml) => new(HttpStatusCode.OK)
+ {
+ Content = new StringContent(xml, System.Text.Encoding.UTF8, "application/xml"),
+ };
+
+ private static MTConnectDriverProbe ProbeWithHandler(
+ Func> respond) =>
+ new(new StubHandler(respond));
+
+ private const string ValidProbeXml =
+ """
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ private const string MTConnectErrorXml =
+ """
+
+
+
+
+ Could not find the device named 'nope'.
+
+
+ """;
+
+ private const string NotMTConnectXml =
+ """
+
+
+ """;
+
+ // ── from the plan (verbatim) ─────────────────────────────────────────────
+
+ [Fact]
+ public async Task Probe_on_unreachable_agent_returns_not_ok_and_does_not_throw()
+ {
+ var probe = new MTConnectDriverProbe();
+ var r = await probe.ProbeAsync("{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromMilliseconds(300), default);
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task Probe_on_blank_config_returns_not_ok()
+ => (await new MTConnectDriverProbe().ProbeAsync("{}", TimeSpan.FromSeconds(1), default)).Ok.ShouldBeFalse();
+
+ // ── unparseable JSON ──────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Unparseable_json_returns_not_ok_and_does_not_throw()
+ {
+ var probe = new MTConnectDriverProbe();
+
+ var r = await probe.ProbeAsync("not valid json {{", QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ r.Message.ShouldContain("invalid", Case.Insensitive);
+ }
+
+ // ── malformed / relative URI ─────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("not a uri")]
+ [InlineData("/relative/path")]
+ [InlineData("ftp://host/probe")]
+ public async Task Malformed_or_non_http_agentUri_returns_not_ok(string agentUri)
+ {
+ var probe = new MTConnectDriverProbe();
+
+ var r = await probe.ProbeAsync($"{{\"agentUri\":\"{agentUri}\"}}", QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ }
+
+ // ── credential redaction ─────────────────────────────────────────────────
+
+ [Fact]
+ public async Task AgentUri_credentials_never_appear_in_the_message()
+ {
+ var probe = new MTConnectDriverProbe();
+
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://sneaky-user:sneaky-pass@127.0.0.1:1/\"}",
+ TimeSpan.FromMilliseconds(300),
+ CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ r.Message.ShouldNotContain("sneaky-pass");
+ r.Message.ShouldNotContain("sneaky-user:sneaky-pass");
+ }
+
+ // ── 200 body that is not MTConnect at all ────────────────────────────────
+
+ [Fact]
+ public async Task NonMTConnect_200_body_returns_not_ok()
+ {
+ var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(NotMTConnectXml)));
+
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ r.Latency.ShouldBeNull();
+ }
+
+ // ── MTConnectError under HTTP 200 ────────────────────────────────────────
+
+ [Fact]
+ public async Task MTConnectError_under_200_returns_not_ok_with_agents_own_message()
+ {
+ var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(MTConnectErrorXml)));
+
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ r.Message.ShouldContain("NO_DEVICE");
+ r.Message.ShouldContain("Could not find the device named 'nope'.");
+ }
+
+ // ── valid MTConnectDevices body ───────────────────────────────────────────
+
+ [Fact]
+ public async Task Valid_probe_document_returns_ok_true_with_latency()
+ {
+ var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(ValidProbeXml)));
+
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeTrue();
+ r.Message.ShouldNotBeNull();
+ r.Latency.ShouldNotBeNull();
+ r.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero);
+ r.Latency!.Value.ShouldBeLessThan(QuickTimeout);
+ }
+
+ // ── timeout bounded (hard hang-risk guard) ───────────────────────────────
+
+ [Fact(Timeout = 5000)]
+ public async Task Frozen_peer_times_out_within_the_requested_deadline_not_hang()
+ {
+ var probe = ProbeWithHandler(async (_, ct) =>
+ {
+ await Task.Delay(Timeout.Infinite, ct); // never answers until cancelled
+ throw new UnreachableException();
+ });
+
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://stub-agent/\"}", TimeSpan.FromMilliseconds(300), CancellationToken.None);
+ sw.Stop();
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ r.Message.ShouldContain("timed out", Case.Insensitive);
+ sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(4));
+ }
+
+ // ── non-positive timeout does not mean "wait forever" ────────────────────
+
+ [Fact(Timeout = 8000)]
+ public async Task Non_positive_timeout_falls_back_to_a_bounded_default_not_infinite()
+ {
+ var probe = ProbeWithHandler(async (_, ct) =>
+ {
+ await Task.Delay(Timeout.Infinite, ct); // never answers until cancelled
+ throw new UnreachableException();
+ });
+
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://stub-agent/\"}", TimeSpan.Zero, CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task Negative_timeout_on_unreachable_agent_still_returns_promptly()
+ {
+ var probe = new MTConnectDriverProbe();
+
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromSeconds(-1), CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ }
+
+ /// Local stand-in so the stub handler's "never returns" branch has a throw statement to satisfy flow analysis.
+ private sealed class UnreachableException : Exception;
+}