mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 22:55: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.
210 lines
9.6 KiB
C#
210 lines
9.6 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using Moq;
|
|
using Opc.Ua.Tests;
|
|
|
|
namespace Opc.Ua.Client.Tests.SessionContexts
|
|
{
|
|
/// <summary>
|
|
/// Exercises real managed dispatch with a deterministic, caller-scripted transport.
|
|
/// </summary>
|
|
internal sealed class SessionRequestContextTestContext : IAsyncDisposable
|
|
{
|
|
/// <summary>
|
|
/// Creates a connected managed session without opening a socket or starting recovery.
|
|
/// </summary>
|
|
internal SessionRequestContextTestContext(ITelemetryContext? telemetry = null)
|
|
{
|
|
Telemetry = telemetry ?? NUnitTelemetryContext.Create();
|
|
TimeProvider = new FakeTimeProvider();
|
|
MessageContext = ServiceMessageContext.Create(Telemetry);
|
|
MessageContext.NamespaceUris.Append("urn:request-context:original");
|
|
var endpointConfiguration = EndpointConfiguration.Create();
|
|
var endpoint = new ConfiguredEndpoint(null, new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://localhost:4840",
|
|
SecurityMode = MessageSecurityMode.None,
|
|
SecurityPolicyUri = SecurityPolicies.None,
|
|
Server = new ApplicationDescription { ApplicationUri = "urn:request-context:server" }
|
|
}, endpointConfiguration);
|
|
var configuration = new ApplicationConfiguration(Telemetry)
|
|
{
|
|
ClientConfiguration = new ClientConfiguration(),
|
|
SecurityConfiguration = new SecurityConfiguration()
|
|
};
|
|
Channel = new Mock<ITransportChannel>();
|
|
Channel.SetupGet(channel => channel.MessageContext).Returns(MessageContext);
|
|
Channel.SetupGet(channel => channel.EndpointDescription).Returns(endpoint.Description);
|
|
Channel.SetupGet(channel => channel.EndpointConfiguration).Returns(endpointConfiguration);
|
|
Channel.SetupGet(channel => channel.OperationTimeout).Returns(10_000);
|
|
Channel.Setup(channel => channel.SendRequestAsync(
|
|
It.IsAny<IServiceRequest>(), It.IsAny<CancellationToken>()))
|
|
.Returns<IServiceRequest, CancellationToken>(DispatchAsync);
|
|
InnerSession = new Session(
|
|
Channel.Object, configuration, endpoint,
|
|
engineFactory: DefaultSubscriptionEngineFactory.Instance,
|
|
timeProvider: TimeProvider);
|
|
InnerSession.SessionCreated(NodeId.Parse("s=original-session"), NodeId.Parse("s=original-token"));
|
|
|
|
var factory = new Mock<ISessionFactory>();
|
|
factory.SetupGet(value => value.Telemetry).Returns(Telemetry);
|
|
ConstructorInfo constructor = typeof(Client.ManagedSession)
|
|
.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)
|
|
.Single();
|
|
ManagedSession = (Client.ManagedSession)constructor.Invoke(
|
|
[
|
|
configuration, endpoint, factory.Object, new ReconnectPolicy(), null,
|
|
Telemetry.CreateLogger<Client.ManagedSession>(), null, null, TimeProvider,
|
|
default(ArrayOf<string>), "RequestContextTests", 60_000u,
|
|
false, false, false, false, null, null, null, null
|
|
]);
|
|
SetField(ManagedSession, typeof(Client.ManagedSession), "m_session", InnerSession);
|
|
SetField(ManagedSession.StateMachine, typeof(ConnectionStateMachine), "m_servicesAvailable", true);
|
|
SetField(ManagedSession.StateMachine, typeof(ConnectionStateMachine), "m_state", ConnectionState.Connected);
|
|
foreach (string name in new[] { "m_servicesReady", "m_settled" })
|
|
{
|
|
var ready = (AsyncManualResetEvent)typeof(ConnectionStateMachine)
|
|
.GetField(name, BindingFlags.NonPublic | BindingFlags.Instance)!
|
|
.GetValue(ManagedSession.StateMachine)!;
|
|
ready.Set();
|
|
}
|
|
ServiceGate = (AsyncReaderWriterLock)typeof(Client.ManagedSession)
|
|
.GetField("m_serviceLock", BindingFlags.NonPublic | BindingFlags.Instance)!
|
|
.GetValue(ManagedSession)!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the telemetry used by the real sessions.
|
|
/// </summary>
|
|
internal ITelemetryContext Telemetry { get; }
|
|
|
|
/// <summary>
|
|
/// Gets controllable time for the recovery drain.
|
|
/// </summary>
|
|
internal FakeTimeProvider TimeProvider { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the original wire message context.
|
|
/// </summary>
|
|
internal IServiceMessageContext MessageContext { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the transport whose lifetime remains owned by the managed session.
|
|
/// </summary>
|
|
internal Mock<ITransportChannel> Channel { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the real session that can be recreated in place.
|
|
/// </summary>
|
|
internal Session InnerSession { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the managed owner passed to the public capture API.
|
|
/// </summary>
|
|
internal Client.ManagedSession ManagedSession { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the existing private gate solely for deterministic race tests.
|
|
/// </summary>
|
|
internal AsyncReaderWriterLock ServiceGate { get; }
|
|
|
|
/// <summary>
|
|
/// Gets all requests actually delivered to the transport.
|
|
/// </summary>
|
|
internal List<IServiceRequest> Requests { get; } = [];
|
|
|
|
/// <summary>
|
|
/// Gets or sets the response script.
|
|
/// </summary>
|
|
internal Func<IServiceRequest, CancellationToken, ValueTask<IServiceResponse>> Handler { get; set; }
|
|
= static (_, _) => new ValueTask<IServiceResponse>(new CloseSessionResponse());
|
|
|
|
/// <summary>
|
|
/// Replaces both server identifiers without changing the inner Session reference.
|
|
/// </summary>
|
|
internal void Recreate()
|
|
{
|
|
InnerSession.SessionCreated(NodeId.Parse("s=replacement-session"), NodeId.Parse("s=replacement-token"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replaces the physical transport without changing public identifiers, as an identity-collision regression.
|
|
/// </summary>
|
|
internal void ReplaceTransport(ITransportChannel channel)
|
|
{
|
|
SetField(InnerSession, typeof(ClientBase), "m_channel", channel);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
InnerSession.AbandonServerSession();
|
|
await ManagedSession.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies a test-only state change without adding a production testing API.
|
|
/// </summary>
|
|
private static void SetField(object target, Type type, string name, object value)
|
|
{
|
|
type.GetField(name, BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(target, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records the request before running the scripted transport operation.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException"></exception>
|
|
private async ValueTask<IServiceResponse> DispatchAsync(IServiceRequest request, CancellationToken ct)
|
|
{
|
|
Requests.Add(request);
|
|
IServiceResponse response = await Handler(request, ct).ConfigureAwait(false);
|
|
ResponseHeader header = response switch
|
|
{
|
|
CallResponse value => value.ResponseHeader ??= new ResponseHeader(),
|
|
HistoryReadResponse value => value.ResponseHeader ??= new ResponseHeader(),
|
|
ReadResponse value => value.ResponseHeader ??= new ResponseHeader(),
|
|
TranslateBrowsePathsToNodeIdsResponse value => value.ResponseHeader ??= new ResponseHeader(),
|
|
CloseSessionResponse value => value.ResponseHeader ??= new ResponseHeader(),
|
|
_ => response.ResponseHeader
|
|
?? throw new InvalidOperationException("The scripted response has no header.")
|
|
};
|
|
header.RequestHandle = request.RequestHeader.RequestHandle;
|
|
return response;
|
|
}
|
|
}
|
|
}
|