mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 14:43:08 +08:00
be50f02700
# 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.
213 lines
9.3 KiB
C#
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!;
|
|
}
|
|
}
|