diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index b76a2be12..339d77ee8 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -92,6 +92,7 @@ Configuration for an MTConnect Agent
| `ignoreTimestamps` | `IgnoreTimestamps` | `bool` | Overwrite timestamps with the agent time. This will correct clock drift but will not give as accurate relative time since it will not take into consideration network latencies. This can be overridden on a per adapter basis. |
| `inputValidationLevel` | `InputValidationLevel` | `InputValidationLevel` | Gets or Sets the default Input (Observation or Asset) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict |
| `observationBufferSize` | `ObservationBufferSize` | `uint` | The maximum number of Observations the agent can hold in its buffer |
+| `sender` | `Sender` | `string` | The value emitted as the Header/@sender attribute on MTConnect response documents (see MTConnect Part 1 §7). When null or empty, falls back to . |
| `timezoneOutput` | `TimeZoneOutput` | `string` | Sets the TimeZone to use when timestamps are output from the Agent |
### `DataSourceConfiguration`
@@ -206,6 +207,7 @@ Configuration for an MTConnect Agent
| `inputValidationLevel` | `InputValidationLevel` | `InputValidationLevel` | Gets the default Input (Observation or Asset) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict |
| `observationBufferSize` | `ObservationBufferSize` | `uint` | The maximum number of Observations the agent can hold in its buffer |
| `path` | `Path` | `string` | The file system path the configuration was loaded from, used as the default target when the configuration is saved back to disk. |
+| `sender` | `Sender` | `string` | The value emitted as the Header/@sender attribute on MTConnect response documents (see MTConnect Part 1 §7). When null or empty, falls back to . |
| `timeZoneOutput` | `TimeZoneOutput` | `string` | Sets the TimeZone to use when timestamps are output from the Agent |
### `IDataSourceConfiguration`
diff --git a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs
index 65420fcfc..a7193872c 100644
--- a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs
+++ b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs
@@ -290,6 +290,8 @@ public MTConnectAgent(
_uuid = !string.IsNullOrEmpty(uuid) ? uuid : Guid.NewGuid().ToString();
_instanceId = instanceId > 0 ? instanceId : CreateInstanceId();
_configuration = configuration != null ? configuration : new AgentConfiguration();
+ if (!string.IsNullOrEmpty(_configuration.Sender))
+ _sender = _configuration.Sender;
_information = new MTConnectAgentInformation(_uuid, _instanceId, _deviceModelChangeTime);
_deviceModelChangeTime = deviceModelChangeTime;
_mtconnectVersion = _configuration != null && _configuration.DefaultVersion != null ? _configuration.DefaultVersion : MTConnectVersions.Max;
diff --git a/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs b/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs
index fd873c6c7..7eaf8ed78 100644
--- a/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs
+++ b/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs
@@ -54,6 +54,12 @@ public class AgentConfiguration : IAgentConfiguration
[YamlIgnore]
public string Path { get; set; }
+ ///
+ /// The value emitted as the Header/@sender attribute on MTConnect response documents (see MTConnect Part 1 §7). When null or empty, falls back to .
+ ///
+ [JsonPropertyName("sender")]
+ public string Sender { get; set; }
+
///
/// The maximum number of Observations the agent can hold in its buffer
diff --git a/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs b/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs
index f6cc210c5..ba86a1814 100644
--- a/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs
+++ b/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs
@@ -21,6 +21,11 @@ public interface IAgentConfiguration
///
string Path { get; }
+ ///
+ /// The value emitted as the Header/@sender attribute on MTConnect response documents (see MTConnect Part 1 §7). When null or empty, falls back to .
+ ///
+ string Sender { get; }
+
///
/// The maximum number of Observations the agent can hold in its buffer
diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderSerializationTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderSerializationTests.cs
new file mode 100644
index 000000000..2b042300a
--- /dev/null
+++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderSerializationTests.cs
@@ -0,0 +1,493 @@
+// Copyright (c) 2026 TrakHound Inc., All Rights Reserved.
+// TrakHound Inc. licenses this file to you under the MIT license.
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using MTConnect.Configurations;
+using NUnit.Framework;
+
+namespace MTConnect.Tests.Common.Agents
+{
+ ///
+ /// Pins the JSON / YAML serialization contract for
+ /// . The property must round-trip
+ /// through both formats via SaveJson / ReadJson and
+ /// SaveYaml / ReadYaml, and it must be tagged with the
+ /// sender wire-name that operator YAML / JSON authors write, so an
+ /// operator-supplied sender: foo-plant-a in agent.config.yaml
+ /// binds through to
+ /// on startup.
+ ///
+ [TestFixture]
+ public class AgentConfigurationSenderSerializationTests
+ {
+ private string _workingDirectory = string.Empty;
+
+ /// Creates a per-test working directory so file writes do not
+ /// contend across parallel fixtures.
+ [SetUp]
+ public void SetUp()
+ {
+ _workingDirectory = Path.Combine(
+ Path.GetTempPath(),
+ "mtconnect-sender-serialization-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_workingDirectory);
+ }
+
+ /// Removes the per-test working directory.
+ [TearDown]
+ public void TearDown()
+ {
+ if (_workingDirectory != null && Directory.Exists(_workingDirectory))
+ {
+ try
+ {
+ Directory.Delete(_workingDirectory, recursive: true);
+ }
+ catch
+ {
+ // Non-fatal cleanup swallow.
+ }
+ }
+ }
+
+ /// Default value of Sender on a freshly constructed
+ /// configuration is null, so the agent falls back to
+ /// until an operator opts in.
+ [Test]
+ public void Sender_default_value_is_null()
+ {
+ var configuration = new AgentConfiguration();
+
+ Assert.That(configuration.Sender, Is.Null);
+ }
+
+ /// The property carries
+ /// the [JsonPropertyName("sender")] attribute so authored JSON
+ /// binds under the lowercase wire-name.
+ [Test]
+ public void Sender_property_wire_name_is_lowercase_sender()
+ {
+ var property = typeof(AgentConfiguration).GetProperty(
+ nameof(AgentConfiguration.Sender),
+ BindingFlags.Public | BindingFlags.Instance);
+
+ Assert.That(property, Is.Not.Null);
+ var jsonAttribute = property!.GetCustomAttribute();
+
+ Assert.That(jsonAttribute, Is.Not.Null);
+ Assert.That(jsonAttribute!.Name, Is.EqualTo("sender"));
+ }
+
+ /// Round-trips through
+ /// the JSON save / read pipeline, pinning that
+ /// emits the value and
+ /// parses it back.
+ [Test]
+ public void Sender_JSON_roundtrips_through_SaveJson_and_ReadJson()
+ {
+ const string PinnedSender = "plant-a-aggregator";
+ var jsonPath = Path.Combine(_workingDirectory, "agent.config.json");
+
+ var original = new AgentConfiguration
+ {
+ Sender = PinnedSender
+ };
+ original.SaveJson(jsonPath, createBackup: false);
+
+ var loaded = AgentConfiguration.ReadJson(jsonPath);
+
+ Assert.That(loaded, Is.Not.Null);
+ Assert.That(loaded.Sender, Is.EqualTo(PinnedSender));
+ Assert.That(loaded.Path, Is.EqualTo(jsonPath));
+ }
+
+ /// Round-trips through
+ /// the YAML save / read pipeline, pinning both directions of the
+ /// operator-facing YAML surface.
+ [Test]
+ public void Sender_YAML_roundtrips_through_SaveYaml_and_ReadYaml()
+ {
+ const string PinnedSender = "plant-b-aggregator";
+ var yamlPath = Path.Combine(_workingDirectory, "agent.config.yaml");
+
+ var original = new AgentConfiguration
+ {
+ Sender = PinnedSender
+ };
+ original.SaveYaml(yamlPath, createBackup: false);
+
+ var loaded = AgentConfiguration.ReadYaml(yamlPath);
+
+ Assert.That(loaded, Is.Not.Null);
+ Assert.That(loaded.Sender, Is.EqualTo(PinnedSender));
+ Assert.That(loaded.Path, Is.EqualTo(yamlPath));
+ }
+
+ /// Authored JSON payloads carrying "sender": "…" bind
+ /// straight through to .
+ [Test]
+ public void Sender_reads_from_authored_JSON_payload()
+ {
+ const string PinnedSender = "gateway-north-1";
+ var jsonPath = Path.Combine(_workingDirectory, "authored.json");
+ File.WriteAllText(
+ jsonPath,
+ "{\n \"sender\": \"" + PinnedSender + "\",\n"
+ + " \"observationBufferSize\": 4096\n}\n");
+
+ var loaded = AgentConfiguration.ReadJson(jsonPath);
+
+ Assert.That(loaded, Is.Not.Null);
+ Assert.That(loaded.Sender, Is.EqualTo(PinnedSender));
+ Assert.That(loaded.ObservationBufferSize, Is.EqualTo(4096));
+ }
+
+ /// Authored YAML payloads carrying sender: … under the
+ /// camel-case naming convention bind to
+ /// .
+ [Test]
+ public void Sender_reads_from_authored_YAML_payload()
+ {
+ const string PinnedSender = "gateway-north-2";
+ var yamlPath = Path.Combine(_workingDirectory, "authored.yaml");
+ File.WriteAllText(
+ yamlPath,
+ "sender: " + PinnedSender + "\n"
+ + "observationBufferSize: 8192\n");
+
+ var loaded = AgentConfiguration.ReadYaml(yamlPath);
+
+ Assert.That(loaded, Is.Not.Null);
+ Assert.That(loaded.Sender, Is.EqualTo(PinnedSender));
+ Assert.That(loaded.ObservationBufferSize, Is.EqualTo(8192));
+ }
+
+ /// Empty-string round-trips
+ /// as empty — the fallback in
+ /// treats null and empty identically.
+ [Test]
+ public void Sender_empty_string_roundtrips_as_empty_string()
+ {
+ var jsonPath = Path.Combine(_workingDirectory, "empty-sender.json");
+ var original = new AgentConfiguration { Sender = string.Empty };
+ original.SaveJson(jsonPath, createBackup: false);
+
+ var loaded = AgentConfiguration.ReadJson(jsonPath);
+
+ Assert.That(loaded, Is.Not.Null);
+ Assert.That(loaded.Sender, Is.EqualTo(string.Empty));
+ }
+
+ /// Multiline / whitespace-preserving values round-trip
+ /// byte-for-byte through the JSON pipeline. Operators occasionally
+ /// author trimmed identifiers with embedded structure; the wire
+ /// format preserves them verbatim.
+ [Test]
+ public void Sender_with_special_characters_roundtrips_verbatim()
+ {
+ const string PinnedSender = "plant/a::region-north:agent-42";
+ var jsonPath = Path.Combine(_workingDirectory, "special-sender.json");
+ var original = new AgentConfiguration { Sender = PinnedSender };
+ original.SaveJson(jsonPath, createBackup: false);
+
+ var loaded = AgentConfiguration.ReadJson(jsonPath);
+
+ Assert.That(loaded, Is.Not.Null);
+ Assert.That(loaded.Sender, Is.EqualTo(PinnedSender));
+
+ var text = File.ReadAllText(jsonPath);
+ using var document = JsonDocument.Parse(text);
+ Assert.That(document.RootElement.GetProperty("sender").GetString(),
+ Is.EqualTo(PinnedSender));
+ }
+
+ // ---------------- surface coverage: Read / Save alternates ----------------
+
+ /// The non-generic ReadJson(Type, path) overload
+ /// deserialises into the specified runtime type and populates the
+ /// operator path field, matching the generic overload's contract.
+ [Test]
+ public void Sender_reads_from_authored_JSON_via_non_generic_Type_overload()
+ {
+ const string PinnedSender = "gateway-nongeneric-json";
+ var jsonPath = Path.Combine(_workingDirectory, "type-json.json");
+ File.WriteAllText(
+ jsonPath,
+ "{\n \"sender\": \"" + PinnedSender + "\"\n}\n");
+
+ var loaded = AgentConfiguration.ReadJson(typeof(AgentConfiguration), jsonPath);
+
+ Assert.That(loaded, Is.Not.Null);
+ Assert.That(loaded.Sender, Is.EqualTo(PinnedSender));
+ Assert.That(loaded.Path, Is.EqualTo(jsonPath));
+ }
+
+ /// The non-generic ReadYaml(Type, path) overload
+ /// deserialises into the specified runtime type and populates the
+ /// operator path field.
+ [Test]
+ public void Sender_reads_from_authored_YAML_via_non_generic_Type_overload()
+ {
+ const string PinnedSender = "gateway-nongeneric-yaml";
+ var yamlPath = Path.Combine(_workingDirectory, "type-yaml.yaml");
+ File.WriteAllText(yamlPath, "sender: " + PinnedSender + "\n");
+
+ var loaded = AgentConfiguration.ReadYaml(typeof(AgentConfiguration), yamlPath);
+
+ Assert.That(loaded, Is.Not.Null);
+ Assert.That(loaded.Sender, Is.EqualTo(PinnedSender));
+ Assert.That(loaded.Path, Is.EqualTo(yamlPath));
+ }
+
+ /// The Read entry-point treats an explicit path as
+ /// YAML per the resolver contract; the value flows through even when
+ /// the file has no extension.
+ [Test]
+ public void Sender_reads_via_top_level_Read_with_explicit_path()
+ {
+ const string PinnedSender = "read-top-level";
+ var path = Path.Combine(_workingDirectory, "explicit-path.yaml");
+ File.WriteAllText(path, "sender: " + PinnedSender + "\n");
+
+ var loaded = AgentConfiguration.Read(path);
+
+ Assert.That(loaded, Is.Not.Null);
+ Assert.That(loaded.Sender, Is.EqualTo(PinnedSender));
+ }
+
+ /// ReadJson returns null when the target path does not exist —
+ /// pinning the file-missing branch that quietly returns null rather
+ /// than throwing.
+ [Test]
+ public void ReadJson_returns_null_when_target_file_is_missing()
+ {
+ var missingPath = Path.Combine(_workingDirectory, "does-not-exist.json");
+
+ var loaded = AgentConfiguration.ReadJson(missingPath);
+
+ Assert.That(loaded, Is.Null);
+ }
+
+ /// ReadJson returns null when the target file is empty.
+ [Test]
+ public void ReadJson_returns_null_when_target_file_is_empty()
+ {
+ var emptyPath = Path.Combine(_workingDirectory, "empty.json");
+ File.WriteAllText(emptyPath, string.Empty);
+
+ var loaded = AgentConfiguration.ReadJson(emptyPath);
+
+ Assert.That(loaded, Is.Null);
+ }
+
+ /// ReadJson swallows deserialisation failures and returns
+ /// null when the payload is malformed JSON.
+ [Test]
+ public void ReadJson_returns_null_when_payload_is_malformed_JSON()
+ {
+ var badPath = Path.Combine(_workingDirectory, "malformed.json");
+ File.WriteAllText(badPath, "{ this is not valid json ");
+
+ var loaded = AgentConfiguration.ReadJson(badPath);
+
+ Assert.That(loaded, Is.Null);
+ }
+
+ /// ReadYaml returns null when the target file is missing.
+ [Test]
+ public void ReadYaml_returns_null_when_target_file_is_missing()
+ {
+ var missingPath = Path.Combine(_workingDirectory, "does-not-exist.yaml");
+
+ var loaded = AgentConfiguration.ReadYaml(missingPath);
+
+ Assert.That(loaded, Is.Null);
+ }
+
+ /// ReadYaml returns null when the target file is empty.
+ [Test]
+ public void ReadYaml_returns_null_when_target_file_is_empty()
+ {
+ var emptyPath = Path.Combine(_workingDirectory, "empty.yaml");
+ File.WriteAllText(emptyPath, string.Empty);
+
+ var loaded = AgentConfiguration.ReadYaml(emptyPath);
+
+ Assert.That(loaded, Is.Null);
+ }
+
+ /// SaveJson with createBackup: true creates a copy of the
+ /// pre-existing target file into the conventional backup directory
+ /// before overwriting. Pins the copy-into-backup-directory contract
+ /// and the copy-preserves-original-content invariant so a regression
+ /// to "backup flag toggles without side-effect" would fail here,
+ /// not just the round-trip line.
+ [Test]
+ public void SaveJson_with_createBackup_copies_existing_target_to_backup_directory()
+ {
+ var jsonPath = Path.Combine(_workingDirectory, "backup.json");
+ File.WriteAllText(jsonPath, "{\"sender\": \"pre-existing\"}\n");
+ var backupDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "backup");
+ var before = SnapshotBackupFiles(backupDir, ".backup.json");
+
+ var updated = new AgentConfiguration { Sender = "post-backup" };
+ updated.SaveJson(jsonPath, createBackup: true);
+
+ var after = SnapshotBackupFiles(backupDir, ".backup.json");
+ var newlyCreated = after.Except(before).ToArray();
+ Assert.That(newlyCreated.Length, Is.EqualTo(1),
+ "createBackup: true must emit exactly one *.backup.json file into the conventional backup directory.");
+ var backupContents = File.ReadAllText(newlyCreated[0]);
+ Assert.That(backupContents, Does.Contain("pre-existing"),
+ "The backup copy must preserve the pre-existing target's contents, not the newly-written value.");
+
+ var loaded = AgentConfiguration.ReadJson(jsonPath);
+ Assert.That(loaded, Is.Not.Null);
+ Assert.That(loaded.Sender, Is.EqualTo("post-backup"));
+ }
+
+ /// SaveJson with createBackup: false emits no *.backup.json
+ /// file — the negative-path companion to the createBackup: true
+ /// assertion. Pins that the backup side-effect is genuinely gated
+ /// on the flag rather than firing unconditionally.
+ [Test]
+ public void SaveJson_with_createBackup_false_does_not_write_backup_file()
+ {
+ var jsonPath = Path.Combine(_workingDirectory, "no-backup.json");
+ File.WriteAllText(jsonPath, "{\"sender\": \"pre-existing\"}\n");
+ var backupDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "backup");
+ var before = SnapshotBackupFiles(backupDir, ".backup.json");
+
+ var updated = new AgentConfiguration { Sender = "post-write" };
+ updated.SaveJson(jsonPath, createBackup: false);
+
+ var after = SnapshotBackupFiles(backupDir, ".backup.json");
+ Assert.That(after.Except(before), Is.Empty,
+ "createBackup: false must produce zero new *.backup.json files in the conventional backup directory.");
+ }
+
+ /// SaveYaml with createBackup: true copies the pre-existing
+ /// target file into the conventional backup directory before
+ /// overwriting. Pins the copy-into-backup-directory contract and
+ /// the copy-preserves-original-content invariant per the JSON
+ /// companion above.
+ [Test]
+ public void SaveYaml_with_createBackup_copies_existing_target_to_backup_directory()
+ {
+ var yamlPath = Path.Combine(_workingDirectory, "backup.yaml");
+ File.WriteAllText(yamlPath, "sender: pre-existing\n");
+ var backupDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "backup");
+ var before = SnapshotBackupFiles(backupDir, ".backup.yaml");
+
+ var updated = new AgentConfiguration { Sender = "post-backup" };
+ updated.SaveYaml(yamlPath, createBackup: true);
+
+ var after = SnapshotBackupFiles(backupDir, ".backup.yaml");
+ var newlyCreated = after.Except(before).ToArray();
+ Assert.That(newlyCreated.Length, Is.EqualTo(1),
+ "createBackup: true must emit exactly one *.backup.yaml file into the conventional backup directory.");
+ var backupContents = File.ReadAllText(newlyCreated[0]);
+ Assert.That(backupContents, Does.Contain("pre-existing"),
+ "The backup copy must preserve the pre-existing target's contents, not the newly-written value.");
+
+ var loaded = AgentConfiguration.ReadYaml(yamlPath);
+ Assert.That(loaded, Is.Not.Null);
+ Assert.That(loaded.Sender, Is.EqualTo("post-backup"));
+ }
+
+ /// SaveYaml with createBackup: false emits no *.backup.yaml
+ /// file — the negative-path companion.
+ [Test]
+ public void SaveYaml_with_createBackup_false_does_not_write_backup_file()
+ {
+ var yamlPath = Path.Combine(_workingDirectory, "no-backup.yaml");
+ File.WriteAllText(yamlPath, "sender: pre-existing\n");
+ var backupDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "backup");
+ var before = SnapshotBackupFiles(backupDir, ".backup.yaml");
+
+ var updated = new AgentConfiguration { Sender = "post-write" };
+ updated.SaveYaml(yamlPath, createBackup: false);
+
+ var after = SnapshotBackupFiles(backupDir, ".backup.yaml");
+ Assert.That(after.Except(before), Is.Empty,
+ "createBackup: false must produce zero new *.backup.yaml files in the conventional backup directory.");
+ }
+
+ /// Snapshots the current set of files under
+ /// whose name ends with
+ /// . Returns an empty array if the
+ /// directory does not yet exist. Used by the backup-assertion tests
+ /// to compute a "before → after" delta that isolates the
+ /// newly-created backup file even when other tests in the same
+ /// run share the process-wide backup directory.
+ /// Absolute path of the process-wide backup directory.
+ /// Filename suffix to match (for example, .backup.json).
+ /// Ordinal-sorted array of absolute paths for files matching .
+ private static string[] SnapshotBackupFiles(string backupDir, string suffix)
+ {
+ if (!Directory.Exists(backupDir)) return Array.Empty();
+ return Directory.EnumerateFiles(backupDir, "*" + suffix, SearchOption.TopDirectoryOnly)
+ .OrderBy(p => p, StringComparer.Ordinal)
+ .ToArray();
+ }
+
+ /// DefaultVersionValue getter returns the canonical
+ /// .ToString() of the current
+ /// .
+ [Test]
+ public void DefaultVersionValue_getter_reflects_DefaultVersion()
+ {
+ var configuration = new AgentConfiguration
+ {
+ DefaultVersion = new Version(2, 7)
+ };
+
+ Assert.That(configuration.DefaultVersionValue, Is.EqualTo("2.7"));
+ }
+
+ /// DefaultVersionValue setter parses a valid version string
+ /// into .
+ [Test]
+ public void DefaultVersionValue_setter_parses_valid_version_string()
+ {
+ var configuration = new AgentConfiguration
+ {
+ DefaultVersionValue = "2.6"
+ };
+
+ Assert.That(configuration.DefaultVersion, Is.EqualTo(new Version(2, 6)));
+ }
+
+ /// DefaultVersionValue setter silently drops an unparseable
+ /// value, leaving
+ /// at its previous state.
+ [Test]
+ public void DefaultVersionValue_setter_drops_unparseable_value()
+ {
+ var configuration = new AgentConfiguration();
+ var originalVersion = configuration.DefaultVersion;
+
+ configuration.DefaultVersionValue = "not-a-version";
+
+ Assert.That(configuration.DefaultVersion, Is.EqualTo(originalVersion));
+ }
+
+ /// DefaultVersionValue setter accepts a null string as a
+ /// no-op, matching the guard on the setter's input branch.
+ [Test]
+ public void DefaultVersionValue_setter_treats_null_as_noop()
+ {
+ var configuration = new AgentConfiguration();
+ var originalVersion = configuration.DefaultVersion;
+
+ configuration.DefaultVersionValue = null;
+
+ Assert.That(configuration.DefaultVersion, Is.EqualTo(originalVersion));
+ }
+ }
+}
diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderTests.cs
new file mode 100644
index 000000000..92d2d2a3e
--- /dev/null
+++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderTests.cs
@@ -0,0 +1,144 @@
+// Copyright (c) 2026 TrakHound Inc., All Rights Reserved.
+// TrakHound Inc. licenses this file to you under the MIT license.
+
+using System.Net;
+using MTConnect.Agents;
+using MTConnect.Configurations;
+using NUnit.Framework;
+
+namespace MTConnect.Tests.Common.Agents
+{
+ ///
+ /// Pins the contract that IAgentConfiguration.Sender, when set,
+ /// flows through to — which populates
+ /// the Header/@sender attribute on every emitted MTConnect response
+ /// document (see MTConnect Part 1 §7). When the config value is absent,
+ /// the pre-existing fallback is preserved
+ /// bit-for-bit, so hosts that do not opt in see no behavioural change.
+ ///
+ [TestFixture]
+ public class AgentConfigurationSenderTests
+ {
+ ///
+ /// When AgentConfiguration.Sender is set, the constructed
+ /// exposes that exact value on its
+ /// property.
+ ///
+ [Test]
+ public void Sender_set_in_config_flows_through_to_Agent_Sender()
+ {
+ const string PinnedSender = "foo-plant-a";
+
+ var configuration = new AgentConfiguration
+ {
+ Sender = PinnedSender,
+ };
+
+ var agent = new MTConnectAgent(
+ configuration,
+ uuid: "sender-fixture-uuid",
+ initializeAgentDevice: false);
+
+ Assert.That(agent.Sender, Is.EqualTo(PinnedSender));
+ }
+
+ ///
+ /// When AgentConfiguration.Sender is null or empty, the agent
+ /// falls back to — matching the
+ /// pre-existing behaviour before the config surface was added.
+ ///
+ [Test]
+ public void Sender_absent_from_config_falls_back_to_Dns_GetHostName()
+ {
+ var configuration = new AgentConfiguration();
+
+ var agent = new MTConnectAgent(
+ configuration,
+ uuid: "sender-fallback-fixture-uuid",
+ initializeAgentDevice: false);
+
+ Assert.That(agent.Sender, Is.EqualTo(Dns.GetHostName()));
+ }
+
+ ///
+ /// When AgentConfiguration.Sender is explicitly the empty
+ /// string, the agent still falls back to
+ /// because the guard on the wire-through branch is
+ /// , which treats null and
+ /// the empty string identically.
+ ///
+ [Test]
+ public void Sender_empty_string_in_config_falls_back_to_Dns_GetHostName()
+ {
+ var configuration = new AgentConfiguration { Sender = string.Empty };
+
+ var agent = new MTConnectAgent(
+ configuration,
+ uuid: "sender-empty-fixture-uuid",
+ initializeAgentDevice: false);
+
+ Assert.That(agent.Sender, Is.EqualTo(Dns.GetHostName()));
+ }
+
+ ///
+ /// A whitespace-only is
+ /// wire-through verbatim — the constructor guard is
+ /// , NOT
+ /// IsNullOrWhiteSpace, so a whitespace value overrides the
+ /// hostname fallback. This test pins the exact null-vs-empty-vs-
+ /// whitespace boundary the setter contract carries.
+ ///
+ [Test]
+ public void Sender_whitespace_only_in_config_is_carried_through_verbatim()
+ {
+ const string PinnedWhitespace = " ";
+ var configuration = new AgentConfiguration { Sender = PinnedWhitespace };
+
+ var agent = new MTConnectAgent(
+ configuration,
+ uuid: "sender-whitespace-fixture-uuid",
+ initializeAgentDevice: false);
+
+ Assert.That(agent.Sender, Is.EqualTo(PinnedWhitespace));
+ }
+
+ ///
+ /// The interface surface
+ /// reflects the value set on the concrete
+ /// — the class's writable setter is
+ /// the only way to author the value, and the interface's getter
+ /// projects it. Pins the polymorphic access path that operator
+ /// integrators reach through the interface abstraction.
+ ///
+ [Test]
+ public void IAgentConfiguration_Sender_getter_reflects_concrete_setter()
+ {
+ const string PinnedSender = "interface-getter-fixture";
+ IAgentConfiguration configuration = new AgentConfiguration
+ {
+ Sender = PinnedSender
+ };
+
+ Assert.That(configuration.Sender, Is.EqualTo(PinnedSender));
+ }
+
+ ///
+ /// Constructing the agent with a null
+ /// argument falls through the constructor's default-config branch
+ /// and therefore falls back to without
+ /// throwing — the null-config path is the historically-supported
+ /// zero-config bootstrap.
+ ///
+ [Test]
+ public void Sender_null_configuration_falls_back_to_Dns_GetHostName()
+ {
+ IAgentConfiguration? configuration = null;
+ var agent = new MTConnectAgent(
+ configuration!,
+ uuid: "sender-null-config-fixture-uuid",
+ initializeAgentDevice: false);
+
+ Assert.That(agent.Sender, Is.EqualTo(Dns.GetHostName()));
+ }
+ }
+}
diff --git a/tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderAllEndpointsWorkflowTests.cs b/tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderAllEndpointsWorkflowTests.cs
new file mode 100644
index 000000000..4ee6e7ea5
--- /dev/null
+++ b/tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderAllEndpointsWorkflowTests.cs
@@ -0,0 +1,238 @@
+// Copyright (c) 2026 TrakHound Inc., All Rights Reserved.
+// TrakHound Inc. licenses this file to you under the MIT license.
+
+using System;
+using System.Net.Http;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+using MTConnect.Agents;
+using MTConnect.Assets.CuttingTools;
+using MTConnect.Configurations;
+using MTConnect.Devices;
+using MTConnect.Servers.Http;
+using Xunit;
+
+namespace MTConnect.Tests.Integration.Workflows
+{
+ ///
+ /// End-to-end companion to
+ /// that widens the
+ /// pinning surface from /probe alone to every MTConnect wire
+ /// endpoint that carries a Header element in its response
+ /// document. MTConnect Part 1 §7 defines the sender attribute
+ /// as an identification of the host / installation that emitted the
+ /// document, and the XSDs declare it on every top-level response
+ /// header — MTConnectDevicesType/Header (/probe),
+ /// MTConnectStreamsType/Header (/current + /sample) and
+ /// MTConnectAssetsType/Header (/asset). The operator-authored
+ /// flows through the broker
+ /// into every one of them without special-casing per endpoint.
+ ///
+ ///
+ /// xUnit v2 instantiates the test class once per test method, so the
+ /// broker + HTTP server are constructed and torn down per test — no
+ /// is wired here because the assertions
+ /// span five independent endpoints (Probe, Current, Sample, Assets,
+ /// SingleAsset) that each want a fresh in-process broker to avoid
+ /// cross-test broker-state bleed. The class is tagged E2E
+ /// (in-process HTTP only, no Docker) so the CI selector filters it
+ /// alongside the other in-process end-to-end fixtures.
+ ///
+ [Trait("Category", "E2E")]
+ public sealed class AgentSenderAllEndpointsWorkflowTests : IDisposable
+ {
+ private const string PinnedSender = "sender-all-endpoints-fixture";
+ private const string DeviceUuid = "sender-all-endpoints-device";
+ private const string DeviceName = "SenderAllEndpointsDevice";
+ private const string AssetId = "SENDER-ALL-ENDPOINTS-ASSET-1";
+
+ private readonly IMTConnectAgentBroker _agent;
+ private readonly MTConnectHttpServer _server;
+ private readonly int _port;
+
+ /// Boots the agent + HTTP server, seeds a Device and an
+ /// asset so the four endpoint tests each have a valid document to
+ /// fetch.
+ public AgentSenderAllEndpointsWorkflowTests()
+ {
+ _port = AllocateLoopbackPort();
+
+ var configuration = new AgentConfiguration
+ {
+ Sender = PinnedSender,
+ DefaultVersion = MTConnectVersions.Version25,
+ };
+ _agent = new MTConnectAgentBroker(configuration);
+ _agent.Start();
+
+ var device = new Device
+ {
+ Id = "senderAllEndpointsDeviceId",
+ Uuid = DeviceUuid,
+ Name = DeviceName,
+ };
+ device.AddDataItem(new DataItem(DataItemCategory.EVENT, "AVAILABILITY", null, "avail"));
+ _agent.AddDevice(device);
+
+ var asset = new CuttingToolAsset
+ {
+ AssetId = AssetId,
+ ToolId = "T1",
+ CuttingToolLifeCycle = new CuttingToolLifeCycle
+ {
+ ProgramToolNumber = "1",
+ ProgramToolGroup = "G1",
+ },
+ Timestamp = DateTime.UtcNow,
+ DeviceUuid = DeviceUuid,
+ };
+ _agent.AddAsset(DeviceUuid, asset);
+
+ var serverConfig = new HttpServerConfiguration
+ {
+ Port = _port,
+ Server = "127.0.0.1",
+ };
+ Exception? startupException = null;
+ _server = new MTConnectHttpServer(serverConfig, _agent);
+ _server.ServerException += (_, ex) => startupException ??= ex;
+ _server.Start();
+ WaitForListener("127.0.0.1", _port, TimeSpan.FromSeconds(30), () => startupException);
+ }
+
+ /// Tears down the fixture — stops the HTTP server and the
+ /// broker, then briefly yields so port + broker background threads
+ /// unwind before the next fixture allocates a new port.
+ public void Dispose()
+ {
+ try { _server?.Stop(); } catch { /* swallow — stop is best-effort */ }
+ try { _agent?.Stop(); } catch { /* swallow — stop is best-effort */ }
+ Thread.Sleep(150);
+ }
+
+ /// /probe response's MTConnectDevices/Header/@sender
+ /// carries the operator-authored
+ /// verbatim, matching the MTConnectDevices XSD's declaration.
+ [Fact]
+ public async Task Probe_Header_sender_matches_configured_Sender()
+ {
+ var body = await GetAsync("probe");
+ Assert.Contains("MTConnectDevices", body);
+ Assert.Contains($"sender=\"{PinnedSender}\"", body);
+ }
+
+ /// /current response's MTConnectStreams/Header/@sender
+ /// carries the operator-authored
+ /// verbatim, matching the MTConnectStreams XSD's declaration.
+ [Fact]
+ public async Task Current_Header_sender_matches_configured_Sender()
+ {
+ var body = await GetAsync("current");
+ Assert.Contains("MTConnectStreams", body);
+ Assert.Contains($"sender=\"{PinnedSender}\"", body);
+ }
+
+ /// /sample response's MTConnectStreams/Header/@sender
+ /// carries the operator-authored
+ /// verbatim — the same streams envelope as /current but populated
+ /// from the observation buffer.
+ [Fact]
+ public async Task Sample_Header_sender_matches_configured_Sender()
+ {
+ var body = await GetAsync("sample");
+ Assert.Contains("MTConnectStreams", body);
+ Assert.Contains($"sender=\"{PinnedSender}\"", body);
+ }
+
+ /// /assets response's MTConnectAssets/Header/@sender
+ /// carries the operator-authored
+ /// verbatim, matching the MTConnectAssets XSD's declaration.
+ [Fact]
+ public async Task Assets_Header_sender_matches_configured_Sender()
+ {
+ var body = await GetAsync("assets");
+ Assert.Contains("MTConnectAssets", body);
+ Assert.Contains(AssetId, body);
+ Assert.Contains($"sender=\"{PinnedSender}\"", body);
+ }
+
+ /// /asset/{id} response for a single asset also carries the
+ /// operator-authored in the
+ /// MTConnectAssets header — the single-asset endpoint reuses the
+ /// same envelope shape as the /assets collection endpoint.
+ [Fact]
+ public async Task SingleAsset_Header_sender_matches_configured_Sender()
+ {
+ var body = await GetAsync($"asset/{AssetId}");
+ Assert.Contains("MTConnectAssets", body);
+ Assert.Contains($"sender=\"{PinnedSender}\"", body);
+ }
+
+ // ---------------- helpers ----------------
+
+ private async Task GetAsync(string path)
+ {
+ using var http = new HttpClient
+ {
+ BaseAddress = new Uri($"http://127.0.0.1:{_port}/"),
+ Timeout = TimeSpan.FromSeconds(15),
+ };
+
+ var response = await http.GetAsync(path);
+ Assert.True(
+ response.IsSuccessStatusCode,
+ $"/{path} returned {(int)response.StatusCode} {response.ReasonPhrase}");
+ return await response.Content.ReadAsStringAsync();
+ }
+
+ private static int AllocateLoopbackPort()
+ {
+ using var listener = new TcpListener(System.Net.IPAddress.Loopback, 0);
+ listener.Start();
+ try
+ {
+ return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
+ }
+ finally
+ {
+ listener.Stop();
+ }
+ }
+
+ private static void WaitForListener(
+ string host,
+ int port,
+ TimeSpan timeout,
+ Func serverStartException)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ var startupException = serverStartException();
+ if (startupException != null)
+ {
+ throw new InvalidOperationException(
+ $"HTTP server failed to start on {host}:{port}: {startupException.Message}",
+ startupException);
+ }
+
+ try
+ {
+ using var client = new TcpClient();
+ client.Connect(host, port);
+ if (client.Connected) return;
+ }
+ catch (SocketException)
+ {
+ // not listening yet
+ }
+
+ Thread.Sleep(100);
+ }
+
+ throw new TimeoutException(
+ $"HTTP listener did not bind to {host}:{port} within {timeout.TotalSeconds}s.");
+ }
+ }
+}
diff --git a/tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderHttpProbeWorkflowTests.cs b/tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderHttpProbeWorkflowTests.cs
new file mode 100644
index 000000000..bec09e420
--- /dev/null
+++ b/tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderHttpProbeWorkflowTests.cs
@@ -0,0 +1,167 @@
+// Copyright (c) 2026 TrakHound Inc., All Rights Reserved.
+// TrakHound Inc. licenses this file to you under the MIT license.
+
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+using MTConnect.Agents;
+using MTConnect.Configurations;
+using MTConnect.Servers.Http;
+using Xunit;
+
+namespace MTConnect.Tests.Integration.Workflows
+{
+ ///
+ /// End-to-end workflow tests for the operator-authored
+ /// surface. Each test boots an
+ /// in-process plus embedded
+ /// , performs a real HTTP GET on
+ /// /probe, and asserts the emitted
+ /// MTConnectDevices/Header/@sender attribute matches the
+ /// operator-authored value; the negative path asserts the pre-existing
+ /// fallback still fires when the config
+ /// value is absent.
+ ///
+ ///
+ /// Sources:
+ ///
+ /// - Prose — MTConnect Part 1 §7 defines Header/@sender as
+ /// "An identification defining where the Agent that published the
+ /// Response Document is installed or hosted."
+ /// - XSD — schemas.mtconnect.org/schemas/MTConnectDevices_2.7.xsd
+ /// declares the sender attribute on the Header element.
+ ///
+ ///
+ [Trait("Category", "E2E")]
+ public sealed class AgentSenderHttpProbeWorkflowTests
+ {
+ private static int s_nextPort = 6400 + (System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 1000));
+
+ private static int AllocatePort() => Interlocked.Increment(ref s_nextPort);
+
+ /// Operator-authored
+ /// flows through the broker + HTTP server to appear as the
+ /// Header/@sender attribute in the /probe response body.
+ [Fact]
+ public async Task Probe_response_Header_sender_matches_configured_Sender()
+ {
+ const string PinnedSender = "foo-plant-a";
+ var port = AllocatePort();
+
+ var configuration = new AgentConfiguration
+ {
+ Sender = PinnedSender
+ };
+
+ var body = await FetchProbeBodyAsync(configuration, port);
+
+ Assert.Contains("MTConnectDevices", body);
+ Assert.Contains($"sender=\"{PinnedSender}\"", body);
+ }
+
+ /// When is not set, the
+ /// /probe response's Header/@sender falls back to
+ /// — the pre-existing behaviour before
+ /// the operator surface was added.
+ [Fact]
+ public async Task Probe_response_Header_sender_falls_back_to_hostname_when_Sender_absent()
+ {
+ var port = AllocatePort();
+
+ var configuration = new AgentConfiguration();
+
+ var body = await FetchProbeBodyAsync(configuration, port);
+ var expected = Dns.GetHostName();
+
+ Assert.Contains("MTConnectDevices", body);
+ Assert.Contains($"sender=\"{expected}\"", body);
+ }
+
+ private static async Task FetchProbeBodyAsync(AgentConfiguration configuration, int port)
+ {
+ var agent = new MTConnectAgentBroker(configuration);
+ agent.Start();
+ try
+ {
+ var serverConfig = new HttpServerConfiguration
+ {
+ Port = port,
+ Server = "127.0.0.1"
+ };
+ Exception? startupException = null;
+ using var server = new MTConnectHttpServer(serverConfig, agent);
+ server.ServerException += (_, ex) => startupException ??= ex;
+ server.Start();
+ try
+ {
+ WaitForListener("127.0.0.1", port, TimeSpan.FromSeconds(30), () => startupException);
+
+ using var http = new HttpClient
+ {
+ BaseAddress = new Uri($"http://127.0.0.1:{port}/"),
+ Timeout = TimeSpan.FromSeconds(15)
+ };
+
+ var response = await http.GetAsync("probe");
+
+ Assert.True(
+ response.IsSuccessStatusCode,
+ $"/probe returned {(int)response.StatusCode} {response.ReasonPhrase}");
+
+ return await response.Content.ReadAsStringAsync();
+ }
+ finally
+ {
+ server.Stop();
+ }
+ }
+ finally
+ {
+ agent.Stop();
+ Thread.Sleep(150);
+ }
+ }
+
+ private static void WaitForListener(
+ string host,
+ int port,
+ TimeSpan timeout,
+ Func serverStartException)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ var startupException = serverStartException();
+ if (startupException != null)
+ {
+ throw new InvalidOperationException(
+ $"HTTP server failed to start on {host}:{port}: {startupException.Message}",
+ startupException);
+ }
+
+ try
+ {
+ using var client = new TcpClient();
+ client.Connect(host, port);
+ if (client.Connected)
+ {
+ return;
+ }
+ }
+ catch (SocketException)
+ {
+ // Not listening yet; keep polling.
+ }
+
+ Thread.Sleep(100);
+ }
+
+ throw new TimeoutException(
+ $"HTTP listener did not bind to {host}:{port} within {timeout.TotalSeconds}s.");
+ }
+ }
+}