diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServer.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServer.java
index 1385f3a4e6..d94db790cc 100644
--- a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServer.java
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServer.java
@@ -47,18 +47,18 @@
import org.eclipse.milo.opcua.sdk.server.namespaces.OpcUaNamespace;
import org.eclipse.milo.opcua.sdk.server.namespaces.ServerNamespace;
import org.eclipse.milo.opcua.sdk.server.nodes.factories.EventFactory;
+import org.eclipse.milo.opcua.sdk.server.servicesets.AttributeServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.DiscoveryServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.MethodServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.MonitoredItemServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.NodeManagementServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.QueryServiceSet;
import org.eclipse.milo.opcua.sdk.server.servicesets.Service;
+import org.eclipse.milo.opcua.sdk.server.servicesets.SessionServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.SubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.ViewServiceSet;
import org.eclipse.milo.opcua.sdk.server.servicesets.impl.AccessController;
import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultAccessController;
-import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultAttributeServiceSet;
-import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultDiscoveryServiceSet;
-import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultMethodServiceSet;
-import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultMonitoredItemServiceSet;
-import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultNodeManagementServiceSet;
-import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultQueryServiceSet;
-import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultSessionServiceSet;
-import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultSubscriptionServiceSet;
-import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultViewServiceSet;
import org.eclipse.milo.opcua.sdk.server.subscriptions.Subscription;
import org.eclipse.milo.opcua.sdk.server.typetree.DataTypeTreeBuilder;
import org.eclipse.milo.opcua.sdk.server.typetree.ObjectTypeTreeBuilder;
@@ -189,6 +189,22 @@ public class OpcUaServer extends AbstractServiceHandler {
private final ServerApplicationContext applicationContext;
public OpcUaServer(OpcUaServerConfig config, OpcServerTransportFactory transportFactory) {
+ this(config, transportFactory, new ServiceSets() {});
+ }
+
+ /**
+ * Create an OpcUaServer using the service set implementations supplied by {@code serviceSets}.
+ *
+ * @param config the {@link OpcUaServerConfig}.
+ * @param transportFactory the {@link OpcServerTransportFactory}.
+ * @param serviceSets the {@link ServiceSets} supplying the service set implementations this
+ * server uses.
+ */
+ public OpcUaServer(
+ OpcUaServerConfig config,
+ OpcServerTransportFactory transportFactory,
+ ServiceSets serviceSets) {
+
this.config = config;
this.transportFactory = transportFactory;
@@ -255,19 +271,31 @@ public ServerTable getServerTable() {
.map(e -> EndpointUtil.getPath(e.getEndpointUrl()))
.distinct();
+ DiscoveryServiceSet discoveryServiceSet = serviceSets.createDiscoveryServiceSet(this);
+ AttributeServiceSet attributeServiceSet = serviceSets.createAttributeServiceSet(this);
+ MethodServiceSet methodServiceSet = serviceSets.createMethodServiceSet(this);
+ MonitoredItemServiceSet monitoredItemServiceSet =
+ serviceSets.createMonitoredItemServiceSet(this);
+ NodeManagementServiceSet nodeManagementServiceSet =
+ serviceSets.createNodeManagementServiceSet(this);
+ QueryServiceSet queryServiceSet = serviceSets.createQueryServiceSet(this);
+ SessionServiceSet sessionServiceSet = serviceSets.createSessionServiceSet(this);
+ SubscriptionServiceSet subscriptionServiceSet = serviceSets.createSubscriptionServiceSet(this);
+ ViewServiceSet viewServiceSet = serviceSets.createViewServiceSet(this);
+
paths.forEach(
path -> {
- addServiceSet(path, new DefaultDiscoveryServiceSet(OpcUaServer.this));
+ addServiceSet(path, discoveryServiceSet);
if (!path.endsWith("/discovery")) {
- addServiceSet(path, new DefaultAttributeServiceSet(OpcUaServer.this));
- addServiceSet(path, new DefaultMethodServiceSet(OpcUaServer.this));
- addServiceSet(path, new DefaultMonitoredItemServiceSet(OpcUaServer.this));
- addServiceSet(path, new DefaultNodeManagementServiceSet(OpcUaServer.this));
- addServiceSet(path, new DefaultQueryServiceSet());
- addServiceSet(path, new DefaultSessionServiceSet(OpcUaServer.this));
- addServiceSet(path, new DefaultSubscriptionServiceSet(OpcUaServer.this));
- addServiceSet(path, new DefaultViewServiceSet(OpcUaServer.this));
+ addServiceSet(path, attributeServiceSet);
+ addServiceSet(path, methodServiceSet);
+ addServiceSet(path, monitoredItemServiceSet);
+ addServiceSet(path, nodeManagementServiceSet);
+ addServiceSet(path, queryServiceSet);
+ addServiceSet(path, sessionServiceSet);
+ addServiceSet(path, subscriptionServiceSet);
+ addServiceSet(path, viewServiceSet);
}
});
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/ServiceSets.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/ServiceSets.java
new file mode 100644
index 0000000000..3378a99320
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/ServiceSets.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server;
+
+import org.eclipse.milo.opcua.sdk.server.servicesets.AttributeServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.DiscoveryServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.MethodServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.MonitoredItemServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.NodeManagementServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.QueryServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.SessionServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.SubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.ViewServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultAttributeServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultDiscoveryServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultMethodServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultMonitoredItemServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultNodeManagementServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultQueryServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultSessionServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultSubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultViewServiceSet;
+
+/**
+ * Factories for the service set implementations an {@link OpcUaServer} uses.
+ *
+ *
Each factory method is invoked once while the server is constructed, and the returned instance
+ * is registered on every applicable endpoint path: the {@link DiscoveryServiceSet} on all paths,
+ * the other service sets on all paths not suffixed with "/discovery".
+ *
+ *
All methods have defaults that return the standard implementations, so an implementation only
+ * overrides the service sets it replaces:
+ *
+ *
{@code
+ * var server = new OpcUaServer(config, transportFactory, new ServiceSets() {
+ * @Override
+ * public DiscoveryServiceSet createDiscoveryServiceSet(OpcUaServer server) {
+ * return new DefaultDiscoveryServiceSet(server, additionalServersSupplier);
+ * }
+ * });
+ * }
+ */
+public interface ServiceSets {
+
+ default AttributeServiceSet createAttributeServiceSet(OpcUaServer server) {
+ return new DefaultAttributeServiceSet(server);
+ }
+
+ default DiscoveryServiceSet createDiscoveryServiceSet(OpcUaServer server) {
+ return new DefaultDiscoveryServiceSet(server);
+ }
+
+ default MethodServiceSet createMethodServiceSet(OpcUaServer server) {
+ return new DefaultMethodServiceSet(server);
+ }
+
+ default MonitoredItemServiceSet createMonitoredItemServiceSet(OpcUaServer server) {
+ return new DefaultMonitoredItemServiceSet(server);
+ }
+
+ default NodeManagementServiceSet createNodeManagementServiceSet(OpcUaServer server) {
+ return new DefaultNodeManagementServiceSet(server);
+ }
+
+ default QueryServiceSet createQueryServiceSet(OpcUaServer server) {
+ return new DefaultQueryServiceSet();
+ }
+
+ default SessionServiceSet createSessionServiceSet(OpcUaServer server) {
+ return new DefaultSessionServiceSet(server);
+ }
+
+ default SubscriptionServiceSet createSubscriptionServiceSet(OpcUaServer server) {
+ return new DefaultSubscriptionServiceSet(server);
+ }
+
+ default ViewServiceSet createViewServiceSet(OpcUaServer server) {
+ return new DefaultViewServiceSet(server);
+ }
+}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultDiscoveryServiceSet.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultDiscoveryServiceSet.java
index a480df6b39..614eed4c39 100644
--- a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultDiscoveryServiceSet.java
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultDiscoveryServiceSet.java
@@ -13,9 +13,11 @@
import static java.util.stream.Collectors.toList;
import static org.eclipse.milo.opcua.sdk.server.servicesets.AbstractServiceSet.createResponseHeader;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
+import java.util.function.Supplier;
import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
import org.eclipse.milo.opcua.sdk.server.servicesets.DiscoveryServiceSet;
@@ -44,9 +46,32 @@ public class DefaultDiscoveryServiceSet implements DiscoveryServiceSet {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final OpcUaServer server;
+ private final Supplier> additionalServers;
public DefaultDiscoveryServiceSet(OpcUaServer server) {
+ this(server, List::of);
+ }
+
+ /**
+ * Create a DefaultDiscoveryServiceSet that includes additional servers in the results of the
+ * FindServers service.
+ *
+ * The supplier is invoked each time the FindServers service is invoked, and may return
+ * different results over time. It must not include the ApplicationDescription of {@code server}
+ * itself; that is always included.
+ *
+ *
This is intended for use by servers that know of other servers they should advertise, e.g.
+ * the other members of a non-transparent redundant server set.
+ *
+ * @param server the {@link OpcUaServer} this service set belongs to.
+ * @param additionalServers a supplier of additional {@link ApplicationDescription}s to include in
+ * the results of the FindServers service.
+ */
+ public DefaultDiscoveryServiceSet(
+ OpcUaServer server, Supplier> additionalServers) {
+
this.server = server;
+ this.additionalServers = additionalServers;
}
@Override
@@ -94,8 +119,9 @@ public FindServersResponse onFindServers(
? List.of(request.getServerUris())
: Collections.emptyList();
- List applicationDescriptions =
- List.of(getFilteredApplicationDescription(request.getEndpointUrl()));
+ List applicationDescriptions = new ArrayList<>();
+ applicationDescriptions.add(getFilteredApplicationDescription(request.getEndpointUrl()));
+ applicationDescriptions.addAll(additionalServers.get());
applicationDescriptions =
applicationDescriptions.stream().filter(ad -> filterServerUris(ad, serverUris)).toList();
diff --git a/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerServiceSetsTest.java b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerServiceSetsTest.java
new file mode 100644
index 0000000000..224ba14f5a
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerServiceSetsTest.java
@@ -0,0 +1,228 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.Mockito.mock;
+
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.milo.opcua.sdk.server.servicesets.AttributeServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.DiscoveryServiceSet;
+import org.eclipse.milo.opcua.sdk.server.servicesets.Service;
+import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultDiscoveryServiceSet;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.security.DefaultCertificateManager;
+import org.eclipse.milo.opcua.stack.core.security.MemoryCertificateQuarantine;
+import org.eclipse.milo.opcua.stack.core.security.SecurityPolicy;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime;
+import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.ApplicationType;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.MessageSecurityMode;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn;
+import org.eclipse.milo.opcua.stack.core.types.structured.ApplicationDescription;
+import org.eclipse.milo.opcua.stack.core.types.structured.FindServersRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.FindServersResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.HistoryReadRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.HistoryReadResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.HistoryUpdateRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.HistoryUpdateResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.ReadRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.ReadResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.ReadValueId;
+import org.eclipse.milo.opcua.stack.core.types.structured.RequestHeader;
+import org.eclipse.milo.opcua.stack.core.types.structured.WriteRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.WriteResponse;
+import org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext;
+import org.junit.jupiter.api.Test;
+
+class OpcUaServerServiceSetsTest {
+
+ private static final ApplicationDescription ADDITIONAL_SERVER =
+ new ApplicationDescription(
+ "urn:eclipse:milo:test:additional",
+ "urn:eclipse:milo:test",
+ LocalizedText.english("additional server"),
+ ApplicationType.Server,
+ null,
+ null,
+ new String[] {"opc.tcp://additional-host:4840/milo/discovery"});
+
+ @Test
+ void customDiscoveryServiceSetIsRegisteredOnEveryEndpointPath() throws Exception {
+ OpcUaServer server =
+ createServer(
+ new ServiceSets() {
+ @Override
+ public DiscoveryServiceSet createDiscoveryServiceSet(OpcUaServer server) {
+ return new DefaultDiscoveryServiceSet(server, () -> List.of(ADDITIONAL_SERVER));
+ }
+ });
+
+ for (String path : List.of("/milo", "/milo/discovery")) {
+ AbstractServiceHandler.ServiceHandler handler =
+ server.getServiceHandler(path, Service.DISCOVERY_FIND_SERVERS);
+ assertNotNull(handler, "expected a FindServers handler at path " + path);
+
+ FindServersResponse response =
+ (FindServersResponse)
+ handler.handle(
+ mock(ServiceRequestContext.class),
+ findServersRequest("opc.tcp://localhost:4840" + path));
+
+ ApplicationDescription[] servers = response.getServers();
+ assertEquals(2, servers.length, "expected the custom service set at path " + path);
+ assertEquals(ADDITIONAL_SERVER, servers[1]);
+ }
+ }
+
+ @Test
+ void customNonDiscoveryServiceSetIsRegisteredOnNonDiscoveryPathsOnly() throws Exception {
+ ReadResponse cannedResponse = new ReadResponse(null, null, null);
+
+ OpcUaServer server =
+ createServer(
+ new ServiceSets() {
+ @Override
+ public AttributeServiceSet createAttributeServiceSet(OpcUaServer server) {
+ return new StubAttributeServiceSet(cannedResponse);
+ }
+ });
+
+ AbstractServiceHandler.ServiceHandler handler =
+ server.getServiceHandler("/milo", Service.ATTRIBUTE_READ);
+ assertNotNull(handler);
+
+ Object response = handler.handle(mock(ServiceRequestContext.class), readRequest());
+ assertSame(cannedResponse, response);
+
+ assertNull(
+ server.getServiceHandler("/milo/discovery", Service.ATTRIBUTE_READ),
+ "non-discovery service sets must not be registered on a /discovery path");
+ }
+
+ @Test
+ void eachFactoryMethodIsInvokedOnceRegardlessOfPathCount() {
+ AtomicInteger discoveryCount = new AtomicInteger();
+ AtomicInteger attributeCount = new AtomicInteger();
+
+ createServer(
+ new ServiceSets() {
+ @Override
+ public DiscoveryServiceSet createDiscoveryServiceSet(OpcUaServer server) {
+ discoveryCount.incrementAndGet();
+ return ServiceSets.super.createDiscoveryServiceSet(server);
+ }
+
+ @Override
+ public AttributeServiceSet createAttributeServiceSet(OpcUaServer server) {
+ attributeCount.incrementAndGet();
+ return ServiceSets.super.createAttributeServiceSet(server);
+ }
+ });
+
+ assertEquals(1, discoveryCount.get());
+ assertEquals(1, attributeCount.get());
+ }
+
+ @Test
+ void defaultConstructorRegistersDefaultServiceSets() {
+ OpcUaServer server = new OpcUaServer(serverConfig(), transportProfile -> null);
+
+ for (String path : List.of("/milo", "/milo/discovery")) {
+ assertNotNull(server.getServiceHandler(path, Service.DISCOVERY_FIND_SERVERS));
+ }
+ assertNotNull(server.getServiceHandler("/milo", Service.ATTRIBUTE_READ));
+ assertNull(server.getServiceHandler("/milo/discovery", Service.ATTRIBUTE_READ));
+ }
+
+ private static OpcUaServer createServer(ServiceSets serviceSets) {
+ return new OpcUaServer(serverConfig(), transportProfile -> null, serviceSets);
+ }
+
+ private static OpcUaServerConfig serverConfig() {
+ Set endpoints = new LinkedHashSet<>();
+ endpoints.add(endpoint("/milo"));
+ endpoints.add(endpoint("/milo/discovery"));
+
+ return OpcUaServerConfig.builder()
+ .setApplicationUri("urn:eclipse:milo:test:server")
+ .setApplicationName(LocalizedText.english("test server"))
+ .setProductUri("urn:eclipse:milo:test")
+ .setCertificateManager(new DefaultCertificateManager(new MemoryCertificateQuarantine()))
+ .setEndpoints(endpoints)
+ .build();
+ }
+
+ private static EndpointConfig endpoint(String path) {
+ return EndpointConfig.newBuilder()
+ .setBindPort(4840)
+ .setHostname("localhost")
+ .setPath(path)
+ .setSecurityPolicy(SecurityPolicy.None)
+ .setSecurityMode(MessageSecurityMode.None)
+ .addTokenPolicy(OpcUaServerConfig.USER_TOKEN_POLICY_ANONYMOUS)
+ .build();
+ }
+
+ private static RequestHeader requestHeader() {
+ return new RequestHeader(
+ NodeId.NULL_VALUE, DateTime.now(), uint(1), uint(0), null, uint(0), null);
+ }
+
+ private static FindServersRequest findServersRequest(String endpointUrl) {
+ return new FindServersRequest(requestHeader(), endpointUrl, null, null);
+ }
+
+ private static ReadRequest readRequest() {
+ return new ReadRequest(requestHeader(), 0.0, TimestampsToReturn.Both, new ReadValueId[0]);
+ }
+
+ private static class StubAttributeServiceSet implements AttributeServiceSet {
+
+ private final ReadResponse readResponse;
+
+ private StubAttributeServiceSet(ReadResponse readResponse) {
+ this.readResponse = readResponse;
+ }
+
+ @Override
+ public ReadResponse onRead(ServiceRequestContext context, ReadRequest request) {
+ return readResponse;
+ }
+
+ @Override
+ public HistoryReadResponse onHistoryRead(
+ ServiceRequestContext context, HistoryReadRequest request) throws UaException {
+ throw new UaException(StatusCodes.Bad_ServiceUnsupported);
+ }
+
+ @Override
+ public WriteResponse onWrite(ServiceRequestContext context, WriteRequest request)
+ throws UaException {
+ throw new UaException(StatusCodes.Bad_ServiceUnsupported);
+ }
+
+ @Override
+ public HistoryUpdateResponse onHistoryUpdate(
+ ServiceRequestContext context, HistoryUpdateRequest request) throws UaException {
+ throw new UaException(StatusCodes.Bad_ServiceUnsupported);
+ }
+ }
+}
diff --git a/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultDiscoveryServiceSetTest.java b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultDiscoveryServiceSetTest.java
new file mode 100644
index 0000000000..89fdc9a04e
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultDiscoveryServiceSetTest.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.servicesets.impl;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServerConfig;
+import org.eclipse.milo.opcua.stack.core.security.DefaultCertificateManager;
+import org.eclipse.milo.opcua.stack.core.security.MemoryCertificateQuarantine;
+import org.eclipse.milo.opcua.stack.core.security.SecurityPolicy;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime;
+import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.ApplicationType;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.MessageSecurityMode;
+import org.eclipse.milo.opcua.stack.core.types.structured.ApplicationDescription;
+import org.eclipse.milo.opcua.stack.core.types.structured.FindServersRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.FindServersResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.RequestHeader;
+import org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext;
+import org.junit.jupiter.api.Test;
+
+class DefaultDiscoveryServiceSetTest {
+
+ private static final String LOCAL_APPLICATION_URI = "urn:eclipse:milo:test:server";
+ private static final String PEER_APPLICATION_URI = "urn:eclipse:milo:test:peer";
+
+ private static final ApplicationDescription PEER_DESCRIPTION =
+ new ApplicationDescription(
+ PEER_APPLICATION_URI,
+ "urn:eclipse:milo:test",
+ LocalizedText.english("peer server"),
+ ApplicationType.Server,
+ null,
+ null,
+ new String[] {"opc.tcp://peer-host:4840/milo/discovery"});
+
+ @Test
+ void emptyAdditionalServersMatchesDefaultBehavior() {
+ OpcUaServer server = createServer();
+
+ FindServersRequest request = findServersRequest("opc.tcp://localhost:4840/milo", null);
+
+ FindServersResponse defaultResponse =
+ new DefaultDiscoveryServiceSet(server)
+ .onFindServers(mock(ServiceRequestContext.class), request);
+ FindServersResponse response =
+ new DefaultDiscoveryServiceSet(server, List::of)
+ .onFindServers(mock(ServiceRequestContext.class), request);
+
+ assertArrayEquals(defaultResponse.getServers(), response.getServers());
+ }
+
+ @Test
+ void additionalServersAreIncludedInFindServersResults() {
+ OpcUaServer server = createServer();
+
+ DefaultDiscoveryServiceSet serviceSet =
+ new DefaultDiscoveryServiceSet(server, () -> List.of(PEER_DESCRIPTION));
+
+ FindServersResponse response =
+ serviceSet.onFindServers(
+ mock(ServiceRequestContext.class),
+ findServersRequest("opc.tcp://localhost:4840/milo", null));
+
+ ApplicationDescription[] servers = response.getServers();
+ assertEquals(2, servers.length);
+ assertEquals(LOCAL_APPLICATION_URI, servers[0].getApplicationUri());
+ assertEquals(PEER_DESCRIPTION, servers[1]);
+ }
+
+ @Test
+ void serverUrisFilterSelectsOnlyAdditionalServer() {
+ OpcUaServer server = createServer();
+
+ DefaultDiscoveryServiceSet serviceSet =
+ new DefaultDiscoveryServiceSet(server, () -> List.of(PEER_DESCRIPTION));
+
+ FindServersResponse response =
+ serviceSet.onFindServers(
+ mock(ServiceRequestContext.class),
+ findServersRequest(
+ "opc.tcp://localhost:4840/milo", new String[] {PEER_APPLICATION_URI}));
+
+ assertArrayEquals(new ApplicationDescription[] {PEER_DESCRIPTION}, response.getServers());
+ }
+
+ @Test
+ void serverUrisFilterSelectsOnlyLocalServer() {
+ OpcUaServer server = createServer();
+
+ DefaultDiscoveryServiceSet serviceSet =
+ new DefaultDiscoveryServiceSet(server, () -> List.of(PEER_DESCRIPTION));
+
+ FindServersResponse response =
+ serviceSet.onFindServers(
+ mock(ServiceRequestContext.class),
+ findServersRequest(
+ "opc.tcp://localhost:4840/milo", new String[] {LOCAL_APPLICATION_URI}));
+
+ ApplicationDescription[] servers = response.getServers();
+ assertEquals(1, servers.length);
+ assertEquals(LOCAL_APPLICATION_URI, servers[0].getApplicationUri());
+ }
+
+ @Test
+ void serverUrisFilterWithUnknownUriReturnsNoServers() {
+ OpcUaServer server = createServer();
+
+ DefaultDiscoveryServiceSet serviceSet =
+ new DefaultDiscoveryServiceSet(server, () -> List.of(PEER_DESCRIPTION));
+
+ FindServersResponse response =
+ serviceSet.onFindServers(
+ mock(ServiceRequestContext.class),
+ findServersRequest("opc.tcp://localhost:4840/milo", new String[] {"urn:unknown"}));
+
+ assertEquals(0, response.getServers().length);
+ }
+
+ private static OpcUaServer createServer() {
+ Set endpoints = new LinkedHashSet<>();
+ endpoints.add(endpoint("/milo"));
+ endpoints.add(endpoint("/milo/discovery"));
+
+ OpcUaServerConfig config =
+ OpcUaServerConfig.builder()
+ .setApplicationUri(LOCAL_APPLICATION_URI)
+ .setApplicationName(LocalizedText.english("test server"))
+ .setProductUri("urn:eclipse:milo:test")
+ .setCertificateManager(new DefaultCertificateManager(new MemoryCertificateQuarantine()))
+ .setEndpoints(endpoints)
+ .build();
+
+ return new OpcUaServer(config, transportProfile -> null);
+ }
+
+ private static EndpointConfig endpoint(String path) {
+ return EndpointConfig.newBuilder()
+ .setBindPort(4840)
+ .setHostname("localhost")
+ .setPath(path)
+ .setSecurityPolicy(SecurityPolicy.None)
+ .setSecurityMode(MessageSecurityMode.None)
+ .addTokenPolicy(OpcUaServerConfig.USER_TOKEN_POLICY_ANONYMOUS)
+ .build();
+ }
+
+ private static FindServersRequest findServersRequest(String endpointUrl, String[] serverUris) {
+ RequestHeader requestHeader =
+ new RequestHeader(NodeId.NULL_VALUE, DateTime.now(), uint(1), uint(0), null, uint(0), null);
+
+ return new FindServersRequest(requestHeader, endpointUrl, null, serverUris);
+ }
+}