Files
UA-.NETStandard/tests/Opc.Ua.Client.Tests/ObjectTypeClientResultTests.cs
Marc Schier be50f02700 Add additional companion spec MCP tool profiles (#4731)
# Description

Expose existing stack APIs through **337 additional domain MCP tools**:
114 industrial-companion tools, 35 WoT/xRegistry/document tools, and 188
higher-level stack-workflow tools. The fully enabled catalogue contains
**492 tools**; profile selection is not an execution grant.

The packages now follow the reviewed structure:
**`Opc.Ua.Mcp.Workflows`** contains roles/users, files, alarms and
history; **`Opc.Ua.Mcp.WotCon`** contains management/documents and
independently registered live WoT execution. GDS and schema tools remain
separate packages. The executable composes **18 libraries**.

| Workflow | Added domain tools | Profiles |
| --- | ---: | --- |
| GDS directory, certificates, reference-only credentials/tokens and
Part 21 onboarding | 46 | `gds`, `certificates`, `credentials`,
`onboarding` |
| Roles/users, regular and temporary files, alarms/conditions and
historical access | 74 | `roles`, `users`, `files`, `alarms`, `history`
|
| Bounded runtime types, XSD/BSD/UA JSON, PubSub and REST OpenAPI
artifacts | 6 | `schemas` |
| Named PubSub transports, remote configuration, declarative
adapters/bridges and SKS | 52 | `pubsub-runtime`,
`pubsub-configuration`, `pubsub-bridge`, `pubsub-security` |
| Live HTTP(S), MQTT, Modbus TCP and OPC UA WoT forms | 10 |
`wot-runtime` |

- Reuse typed clients, generated proxies, SDK transports, schema
providers and injected services rather than introducing another session
manager or protocol engine.
- Require host enablement plus exact caller/session/secret/network
grants. Protected generic Call/Write/node-management/history/file paths
enforce the same policy.
- Bind classifications, dispatches, file handles and historical cursors
to actual UA generations. Recovery cannot retarget an old handle or
authorized mutation. Preserve acknowledged results after late
cancellation; do not imply atomicity or retry business commands.
- Use opaque secret-store references, explicit writable-store
registration, issuer/resource/caller binding and reserved capacity
before issuance. Report remote success/local persistence failure as
partial completion; redact provider-controlled diagnostic text.
- Bound new named runtimes to eight per host, two per caller,
five-minute idle expiry and a thirty-minute absolute lease by default.
Pending/closing resources consume quota; failed cleanup remains visible.
Credential invalidation and session recovery close dependent runtimes.
- Keep document planning separate from execution, artifacts
root-confined and real JSON inputs typed. Preserve old profile ordinals
while widening the set to 64 bits. CI grades fully enabled privileged
catalogues rather than connection-only views.

**Limitations:** live Kafka remains explicitly unavailable because the
backend cannot enforce policy on broker-discovered connections;
configuration/schema/capability inspection stays available. The latest
public Dekaf v1.23.5 was also checked and its DNS injection remains
internal. No private-API workaround or bootstrap-only security claim was
added. Full legacy Core registration retains its documented
JSON-reflection limitation: native tests isolate its four connection
descriptors and validate the expanded production registrations, not
whole-host NativeAOT support.

## Related Issues

- Fixes #4730.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
2026-10-14 00:14:05 +02:00

213 lines
9.3 KiB
C#

/* Copyright (c) 2026 The OPC Foundation, Inc. All rights reserved.
* Licensed under the MIT License. See LICENSE.txt in the project root. */
using System;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Tests;
namespace Opc.Ua.Client.Tests
{
/// <summary>
/// The result-bearing method path must not inherit the retrying proxy fallback.
/// </summary>
[TestFixture]
[Category("Client")]
[Parallelizable]
public sealed class ObjectTypeClientResultTests
{
[SetUp]
public void SetUp()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
m_context = ServiceMessageContext.Create(telemetry);
m_context.NamespaceUris.GetIndexOrAppend(kNamespace);
m_session = new Mock<ISessionClient>(MockBehavior.Strict);
m_session.SetupGet(s => s.MessageContext).Returns(m_context);
m_client = new ResultProxy(m_session.Object, new NodeId("object", 1), telemetry);
}
[TestCaseSource(nameof(s_methodStatuses))]
public async Task CallWithResultAsyncPreservesMethodAndArgumentResultsWithoutRetryAsync(StatusCode status)
{
using var cancellation = new CancellationTokenSource();
var argumentDiagnostic = new DiagnosticInfo { AdditionalInfo = "argument detail" };
var expected = new CallMethodResult
{
StatusCode = status,
InputArgumentResults = [StatusCodes.Good, StatusCodes.BadOutOfRange],
InputArgumentDiagnosticInfos = [new DiagnosticInfo(), argumentDiagnostic],
OutputArguments = [Variant.From(42), Variant.From("assigned-version")]
};
CallMethodRequest? captured = null;
CancellationToken receivedToken = default;
m_session.Setup(s => s.CallAsync(
It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(),
It.IsAny<CancellationToken>()))
.Returns<RequestHeader, ArrayOf<CallMethodRequest>, CancellationToken>((_, requests, token) =>
{
Assert.That(requests.Count, Is.EqualTo(1));
captured = requests[0];
receivedToken = token;
return new ValueTask<CallResponse>(new CallResponse
{
ResponseHeader = new ResponseHeader(),
Results = [expected]
});
});
CallMethodResult actual = await m_client.CallWithResultAsync(
new ExpandedNodeId(91u, kNamespace), [Variant.From("input"), Variant.From(7u)],
cancellation.Token).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(actual.StatusCode, Is.EqualTo(status));
Assert.That(actual.InputArgumentResults,
Is.EqualTo(new StatusCode[] { StatusCodes.Good, StatusCodes.BadOutOfRange }));
Assert.That(actual.InputArgumentDiagnosticInfos[1].AdditionalInfo, Is.EqualTo("argument detail"));
Assert.That(actual.OutputArguments,
Is.EqualTo(new[] { Variant.From(42), Variant.From("assigned-version") }));
Assert.That(captured!.ObjectId, Is.EqualTo(new NodeId("object", 1)));
Assert.That(captured.MethodId, Is.EqualTo(new NodeId(91u, 1)));
Assert.That(captured.InputArguments, Is.EqualTo(new[] { Variant.From("input"), Variant.From(7u) }));
Assert.That(receivedToken, Is.EqualTo(cancellation.Token));
});
VerifyOneCallAndNoFallback();
}
[TestCase("missing-result")]
[TestCase("extra-result")]
[TestCase("extra-diagnostic")]
public void CallWithResultAsyncRejectsInvalidResponseCardinality(string shape)
{
var response = new CallResponse
{
ResponseHeader = new ResponseHeader(),
Results = [new CallMethodResult { StatusCode = StatusCodes.Good }]
};
if (shape == "missing-result")
{
response.Results = [];
}
else if (shape == "extra-result")
{
response.Results = [new CallMethodResult(), new CallMethodResult()];
}
else
{
response.DiagnosticInfos = [new DiagnosticInfo(), new DiagnosticInfo()];
}
SetupResponse(response);
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
async () => await m_client.CallWithResultAsync(
new ExpandedNodeId(91u, kNamespace), []).ConfigureAwait(false))!;
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
VerifyOneCallAndNoFallback();
}
[Test]
public void CallWithResultAsyncRejectsBadServiceStatusEvenWithGoodMethodResult()
{
SetupResponse(new CallResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.BadSessionClosed },
Results =
[new CallMethodResult { StatusCode = StatusCodes.Good, OutputArguments = [Variant.From(123)] }]
});
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
async () => await m_client.CallWithResultAsync(
new ExpandedNodeId(91u, kNamespace), []).ConfigureAwait(false))!;
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadSessionClosed));
VerifyOneCallAndNoFallback();
}
[Test]
public void CallWithResultAsyncPropagatesServiceExceptionWithoutRetry()
{
var primary = new ServiceResultException(StatusCodes.BadTimeout);
m_session.Setup(s => s.CallAsync(
It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(),
It.IsAny<CancellationToken>()))
.Throws(primary);
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
async () => await m_client.CallWithResultAsync(
new ExpandedNodeId(91u, kNamespace), []).ConfigureAwait(false))!;
Assert.That(exception, Is.SameAs(primary));
VerifyOneCallAndNoFallback();
}
[TestCase(false)]
[TestCase(true)]
public void CallWithResultAsyncRejectsNonLocalMethodWithoutDispatch(bool remote)
{
ExpandedNodeId method = remote
? new ExpandedNodeId(new NodeId(91u, 1), null, 3)
: new ExpandedNodeId(91u, "urn:missing:method");
int namespaceCount = m_context.NamespaceUris.Count;
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
async () => await m_client.CallWithResultAsync(method, []).ConfigureAwait(false))!;
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadNotSupported));
Assert.That(m_context.NamespaceUris.Count, Is.EqualTo(namespaceCount));
m_session.Verify(s => s.CallAsync(
It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[Test]
public void CallWithResultAsyncRejectsNullProxy()
{
ArgumentNullException exception = Assert.ThrowsAsync<ArgumentNullException>(
async () => await ObjectTypeClientExtensions.CallWithResultAsync(
null!, new ExpandedNodeId(91u, kNamespace), []).ConfigureAwait(false))!;
Assert.That(exception.ParamName, Is.EqualTo("client"));
}
private void SetupResponse(CallResponse response)
{
m_session.Setup(s => s.CallAsync(
It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<CallResponse>(response));
}
private void VerifyOneCallAndNoFallback()
{
m_session.Verify(s => s.CallAsync(
It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(),
It.IsAny<CancellationToken>()), Times.Once);
m_session.Verify(s => s.TranslateBrowsePathsToNodeIdsAsync(
It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<BrowsePath>>(),
It.IsAny<CancellationToken>()), Times.Never);
}
private sealed class ResultProxy : ObjectTypeClient
{
public ResultProxy(ISessionClient session, NodeId id, ITelemetryContext telemetry)
: base(session, id, telemetry)
{
}
}
private const string kNamespace = "urn:test:result-method";
private static readonly StatusCode[] s_methodStatuses =
[
StatusCodes.Good, StatusCodes.GoodResultsMayBeIncomplete, StatusCodes.Uncertain,
StatusCodes.BadMethodInvalid, StatusCodes.BadInvalidArgument
];
private ServiceMessageContext m_context = null!;
private Mock<ISessionClient> m_session = null!;
private ResultProxy m_client = null!;
}
}