mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 16:52:07 +08:00
87dcfc10cf
Brings the packet-capture / dissection / replay engine (Opc.Ua.Bindings.Pcap) onto the channel-refinements branch so it composes cleanly with the central IClientChannelManager work landed in PR #3852 (channelrefinements). Conflicts resolved: - Applications/McpServer/Program.cs: union of both ConfigureServices changes — registers AddOpcUa().AddClient() (channel manager) AND AddOpcUaBindingsPcap() / Formatters / Replay (capture). Channel manager picks up the capture binding automatically via the shared TransportBindings.Channels registry. - Stack/Opc.Ua.Core/Stack/Tcp/TcpListenerChannel.cs: keep both m_sessionCount (channelrefinements2) and m_tokenActivated event (origin/capture). Server-side capture relies on TcpListenerChannel.OnTokenActivated to snapshot per-channel ChannelToken keys for offline decoding. - UA.slnx: keep both Opc.Ua.Di.Tests and Opc.Ua.Bindings.Pcap.Tests project entries. Pre-existing fix folded in (build-blocker introduced earlier on channelrefinements2, not by the capture merge): - Applications/Quickstarts.Servers/ReferenceServer/ReferenceServerConfigurationNodeManager.cs s_optionalPubSubFolders previously referenced ObjectIds.SecurityGroups and ObjectIds.KeyPushTargets, neither of which exists on the curated Stack/Opc.Ua.Types/Internal/ObjectIds.cs surface. Inlined the NodeId literals (i=15443 / i=25440) per the docstring above the array so the merged tree builds end-to-end across all TFMs and every transitive test project (Tests/Opc.Ua.Client.Tests, Tests/Opc.Ua.Sessions.Tests, etc.) compiles. Architectural fit ("take advantage of all the changes you made here"): 1. Pcap integrates with the central channel manager via the global TransportBindings.Channels registry. ClientChannelManager.ctor accepts an injected ITransportChannelBindings? that defaults to the global registry, so the Pcap binding installed by AddOpcUaBindingsPcap is automatically picked up by every channel created through the manager. No code wiring needed — the composition is pure layering. 2. Channel sharing transparently includes capture. When multiple participants share a single IManagedTransportChannel (the F1 refcount + key-equivalence work), the underlying CapturingMessageSocket captures all participant traffic on the same wire, automatically. 3. Transparent reconnect preserves the capture session. The F8 diagnostics events on ClientChannelManager (state-change / close notifications) and the Phase E SwapFaultedEntryAsync swap path produce fresh ChannelEntry instances under the same ManagedChannelKey — IChannelCaptureRegistry is keyed by channel identity, so a swap rolls the active observer into the new entry and the capture session continues recording across the reconnect / faulted-swap cycle. 4. MCP server composes both: services.AddOpcUa().AddClient() (channel manager + DI), services.AddSingleton<OpcUaSessionManager>(), services.AddOpcUaBindingsPcap() (capture binding). OpcUaSessionManager resolves IClientChannelManager from IServiceProvider; the Pcap tools (Tools/PacketCapture/Decode/ReplayTools.cs) resolve CaptureSessionManager and IChannelCaptureRegistry from the same provider. Verified locally on net10.0: - Stack/Opc.Ua.Bindings.Pcap build: 0 warnings, 0 errors. - Libraries/Opc.Ua.Client build (channel manager): 0 warnings, 0 errors. - Tests/Opc.Ua.Bindings.Pcap.Tests: 53/53 passed. - Tests/Opc.Ua.Client.Tests ClientChannelManager filter: 29/29 passed. - Tests/Opc.Ua.Sessions.Tests ChannelManager filter: 7/7 passed, 1 skipped (RequiresSessionRecreate carry-forward). - Applications/McpServer build: 0 warnings, 0 errors.
683 lines
26 KiB
C#
683 lines
26 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.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Configuration;
|
|
using Opc.Ua.Security.Certificates;
|
|
|
|
namespace Opc.Ua.Mcp
|
|
{
|
|
/// <summary>
|
|
/// Manages the OPC UA client session lifecycle for the MCP server.
|
|
/// </summary>
|
|
public sealed class OpcUaSessionManager : IDisposable
|
|
{
|
|
private const string kApplicationName = "OPC UA MCP Server";
|
|
private const string kConfigSectionName = "Opc.Ua.Mcp";
|
|
private const string kConfigFileName = "Opc.Ua.Mcp.Config.xml";
|
|
|
|
private readonly ILogger<OpcUaSessionManager> m_logger;
|
|
private readonly IServiceProvider m_serviceProvider;
|
|
private readonly OpcUaClientOptions m_clientOptions;
|
|
private readonly ITelemetryContext m_telemetry;
|
|
private readonly SemaphoreSlim m_lock = new(1, 1);
|
|
private readonly ConcurrentDictionary<string, SessionInfo> m_sessions = new(StringComparer.OrdinalIgnoreCase);
|
|
private ApplicationConfiguration? m_configuration;
|
|
private bool m_disposed;
|
|
|
|
public OpcUaSessionManager(
|
|
ILogger<OpcUaSessionManager> logger,
|
|
IServiceProvider serviceProvider,
|
|
OpcUaClientOptions clientOptions,
|
|
ITelemetryContext telemetry)
|
|
{
|
|
m_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
m_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
|
m_clientOptions = clientOptions ?? throw new ArgumentNullException(nameof(clientOptions));
|
|
m_telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Holds information about a named OPC UA session.
|
|
/// </summary>
|
|
public sealed class SessionInfo
|
|
{
|
|
public required string Name { get; init; }
|
|
public required ISession Session { get; init; }
|
|
public required EndpointDescription Endpoint { get; init; }
|
|
public required string AuthType { get; init; }
|
|
public DateTime ConnectedAt { get; init; } = DateTime.UtcNow;
|
|
public bool IsConnected => Session.Connected;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the first session, or null if none connected. For backward compatibility.
|
|
/// </summary>
|
|
public ISession? Session => m_sessions.Values.FirstOrDefault()?.Session;
|
|
|
|
/// <summary>
|
|
/// Gets the telemetry context used by this session manager.
|
|
/// </summary>
|
|
public ITelemetryContext Telemetry => m_telemetry;
|
|
|
|
/// <summary>
|
|
/// Gets the loaded application configuration, or null if not yet loaded.
|
|
/// </summary>
|
|
public ApplicationConfiguration? Configuration => m_configuration;
|
|
|
|
/// <summary>
|
|
/// Gets the active transport channels for connected sessions.
|
|
/// </summary>
|
|
public IReadOnlyList<ITransportChannel> GetActiveTransportChannels()
|
|
{
|
|
return [.. m_sessions.Values
|
|
.Where(static info => info.IsConnected)
|
|
.Select(static info => info.Session.TransportChannel)
|
|
.OfType<ITransportChannel>()];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensures the application configuration is loaded, loading it if necessary.
|
|
/// </summary>
|
|
public async Task<ApplicationConfiguration> EnsureConfigurationAsync(CancellationToken ct = default)
|
|
{
|
|
if (m_configuration != null)
|
|
{
|
|
return m_configuration;
|
|
}
|
|
|
|
await m_lock.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
await EnsureConfigurationInternalAsync(false, ct).ConfigureAwait(false);
|
|
return m_configuration!;
|
|
}
|
|
finally
|
|
{
|
|
m_lock.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets whether any session is currently connected.
|
|
/// </summary>
|
|
public bool IsConnected => m_sessions.Values.Any(s => s.IsConnected);
|
|
|
|
/// <summary>
|
|
/// Gets a session by name, or the only active session if name is null.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException">Not connected or ambiguous session.</exception>
|
|
public ISession GetSessionOrThrow(string? name = null)
|
|
{
|
|
if (name != null)
|
|
{
|
|
if (m_sessions.TryGetValue(name, out SessionInfo? info) && info.IsConnected)
|
|
{
|
|
return info.Session;
|
|
}
|
|
|
|
throw new InvalidOperationException(
|
|
$"Session '{name}' not found or not connected. Use the 'Connect' tool first.");
|
|
}
|
|
|
|
int count = m_sessions.Count;
|
|
if (count == 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Not connected to an OPC UA server. Use the 'Connect' tool first.");
|
|
}
|
|
|
|
if (count == 1)
|
|
{
|
|
SessionInfo single = m_sessions.Values.First();
|
|
if (single.IsConnected)
|
|
{
|
|
return single.Session;
|
|
}
|
|
|
|
throw new InvalidOperationException(
|
|
"Not connected to an OPC UA server. Use the 'Connect' tool first.");
|
|
}
|
|
|
|
string names = string.Join(", ", m_sessions.Keys);
|
|
throw new InvalidOperationException(
|
|
$"Multiple sessions active. Specify a sessionName: {names}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets all active sessions.
|
|
/// </summary>
|
|
public IReadOnlyCollection<SessionInfo> GetAllSessions()
|
|
{
|
|
return m_sessions.Values.ToList().AsReadOnly();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets information about a specific named session.
|
|
/// </summary>
|
|
public SessionInfo? GetSessionInfo(string name)
|
|
{
|
|
return m_sessions.GetValueOrDefault(name);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Discovers all endpoints available at the given discovery URL.
|
|
/// </summary>
|
|
public async Task<ArrayOf<EndpointDescription>> DiscoverEndpointsAsync(
|
|
string discoveryUrl,
|
|
CancellationToken ct)
|
|
{
|
|
ObjectDisposedException.ThrowIf(m_disposed, this);
|
|
|
|
await EnsureConfigurationInternalAsync(false, ct).ConfigureAwait(false);
|
|
|
|
var uri = new Uri(discoveryUrl);
|
|
var endpointConfiguration = EndpointConfiguration.Create(m_configuration!);
|
|
|
|
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
|
|
m_configuration!,
|
|
uri,
|
|
endpointConfiguration,
|
|
ct: ct).ConfigureAwait(false);
|
|
|
|
return await client.GetEndpointsAsync(default, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Connects to an OPC UA server with endpoint selection and authentication options.
|
|
/// </summary>
|
|
/// <exception cref="ServiceResultException"></exception>
|
|
public async Task<string> ConnectAsync(
|
|
string? name,
|
|
string endpointUrl,
|
|
string? securityMode,
|
|
string? securityPolicy,
|
|
string authType,
|
|
string? username,
|
|
string? password,
|
|
bool autoAcceptCerts,
|
|
CancellationToken ct)
|
|
{
|
|
ObjectDisposedException.ThrowIf(m_disposed, this);
|
|
|
|
await m_lock.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
// Auto-generate name from endpoint URL if not specified
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
{
|
|
var uri = new Uri(endpointUrl);
|
|
name = string.Format(
|
|
CultureInfo.InvariantCulture,
|
|
"{0}-{1}",
|
|
uri.Host,
|
|
uri.Port);
|
|
}
|
|
|
|
// Disconnect existing session with same name
|
|
if (m_sessions.TryGetValue(name, out SessionInfo? existing))
|
|
{
|
|
await DisconnectInternalAsync(existing, ct).ConfigureAwait(false);
|
|
m_sessions.TryRemove(name, out _);
|
|
}
|
|
|
|
await EnsureConfigurationInternalAsync(autoAcceptCerts, ct).ConfigureAwait(false);
|
|
|
|
if (autoAcceptCerts)
|
|
{
|
|
m_configuration!.CertificateManager.AcceptError = AutoAcceptError;
|
|
}
|
|
|
|
m_logger.LogInformation("Connecting to {EndpointUrl} as '{Name}'...", endpointUrl, name);
|
|
|
|
EndpointDescription selectedEndpoint = await SelectEndpointAsync(
|
|
endpointUrl, securityMode, securityPolicy, authType, ct).ConfigureAwait(false);
|
|
|
|
UserIdentity identity = BuildUserIdentity(authType, username, password);
|
|
|
|
ApplicationConfiguration configuration = m_configuration!;
|
|
var endpointConfiguration = EndpointConfiguration.Create(configuration);
|
|
var endpoint = new ConfiguredEndpoint(null, selectedEndpoint, endpointConfiguration);
|
|
IClientChannelManager channelManager = m_serviceProvider.GetRequiredService<IClientChannelManager>();
|
|
|
|
ManagedSession session = await new ManagedSessionBuilder(configuration, Telemetry)
|
|
.UseEndpoint(endpoint)
|
|
.WithChannelManager(channelManager)
|
|
.WithSessionName(configuration.ApplicationName ?? kApplicationName)
|
|
.WithSessionTimeout(TimeSpan.FromMilliseconds(60_000))
|
|
.WithUserIdentity(identity)
|
|
.ConnectAsync(ct)
|
|
.ConfigureAwait(false);
|
|
|
|
session.KeepAliveInterval = 5000;
|
|
session.ConnectionStateChanged += (_, e) => SessionConnectionStateChanged(name, e);
|
|
session.ChannelStateChanged += (_, e) => SessionChannelStateChanged(name, e);
|
|
|
|
var sessionInfo = new SessionInfo
|
|
{
|
|
Name = name,
|
|
Session = session,
|
|
Endpoint = selectedEndpoint,
|
|
AuthType = authType,
|
|
ConnectedAt = DateTime.UtcNow
|
|
};
|
|
|
|
m_sessions[name] = sessionInfo;
|
|
|
|
m_logger.LogInformation(
|
|
"Connected '{Name}'. SessionName={SessionName}, SessionId={SessionId}",
|
|
name,
|
|
session.SessionName,
|
|
session.SessionId);
|
|
|
|
return string.Format(
|
|
CultureInfo.InvariantCulture,
|
|
"Connected to {0} as '{1}'. SecurityMode={2}, SecurityPolicy={3}, Auth={4}, " +
|
|
"SessionName={5}, SessionId={6}",
|
|
endpointUrl,
|
|
name,
|
|
selectedEndpoint.SecurityMode,
|
|
selectedEndpoint.SecurityPolicyUri,
|
|
authType,
|
|
session.SessionName,
|
|
session.SessionId);
|
|
}
|
|
finally
|
|
{
|
|
m_lock.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disconnects a named session, or the only session if name is null.
|
|
/// </summary>
|
|
public async Task<string> DisconnectAsync(string? name = null, CancellationToken ct = default)
|
|
{
|
|
await m_lock.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (name != null)
|
|
{
|
|
if (!m_sessions.TryRemove(name, out SessionInfo? info))
|
|
{
|
|
return $"Session '{name}' not found.";
|
|
}
|
|
|
|
await DisconnectInternalAsync(info, ct).ConfigureAwait(false);
|
|
return $"Disconnected session '{name}'.";
|
|
}
|
|
|
|
int count = m_sessions.Count;
|
|
if (count == 0)
|
|
{
|
|
return "No active sessions to disconnect.";
|
|
}
|
|
|
|
if (count > 1)
|
|
{
|
|
string names = string.Join(", ", m_sessions.Keys);
|
|
return $"Multiple sessions active. Specify a session name to disconnect: {names}";
|
|
}
|
|
|
|
// Exactly one session
|
|
KeyValuePair<string, SessionInfo> single = m_sessions.First();
|
|
m_sessions.TryRemove(single.Key, out _);
|
|
await DisconnectInternalAsync(single.Value, ct).ConfigureAwait(false);
|
|
return $"Disconnected session '{single.Key}'.";
|
|
}
|
|
finally
|
|
{
|
|
m_lock.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets connection status information for one or all sessions.
|
|
/// </summary>
|
|
public string GetConnectionStatus(string? name = null)
|
|
{
|
|
if (name != null)
|
|
{
|
|
if (!m_sessions.TryGetValue(name, out SessionInfo? info))
|
|
{
|
|
return $"Session '{name}' not found.";
|
|
}
|
|
|
|
return FormatSessionStatus(info);
|
|
}
|
|
|
|
if (m_sessions.IsEmpty)
|
|
{
|
|
return "Not connected.";
|
|
}
|
|
|
|
var lines = new List<string>();
|
|
foreach (SessionInfo info in m_sessions.Values)
|
|
{
|
|
lines.Add(FormatSessionStatus(info));
|
|
}
|
|
|
|
return string.Join(Environment.NewLine, lines);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (m_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
m_disposed = true;
|
|
foreach (SessionInfo info in m_sessions.Values)
|
|
{
|
|
info.Session.Dispose();
|
|
}
|
|
|
|
m_sessions.Clear();
|
|
m_lock.Dispose();
|
|
}
|
|
|
|
private static async Task DisconnectInternalAsync(SessionInfo info, CancellationToken ct)
|
|
{
|
|
await info.Session.CloseAsync(ct).ConfigureAwait(false);
|
|
info.Session.Dispose();
|
|
}
|
|
|
|
private async Task EnsureConfigurationInternalAsync(bool autoAcceptCerts, CancellationToken ct)
|
|
{
|
|
if (m_configuration != null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
#pragma warning disable CA2000 // Dispose objects before losing scope
|
|
var application = new ApplicationInstance(Telemetry)
|
|
{
|
|
ApplicationName = kApplicationName,
|
|
ApplicationType = ApplicationType.Client,
|
|
ConfigSectionName = kConfigSectionName
|
|
};
|
|
#pragma warning restore CA2000 // Dispose objects before losing scope
|
|
|
|
string configFile = Path.Combine(AppContext.BaseDirectory, kConfigFileName);
|
|
ApplicationConfiguration config;
|
|
if (File.Exists(configFile))
|
|
{
|
|
config = await application.LoadApplicationConfigurationAsync(configFile, false, ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
config = await application.LoadApplicationConfigurationAsync(false, ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
bool hasAppCert = await application.CheckApplicationInstanceCertificatesAsync(false, ct: ct)
|
|
.ConfigureAwait(false);
|
|
if (!hasAppCert)
|
|
{
|
|
m_logger.LogWarning("Application certificate not found. Security may be limited.");
|
|
}
|
|
|
|
if (autoAcceptCerts)
|
|
{
|
|
config.CertificateManager.AcceptError = AutoAcceptError;
|
|
}
|
|
|
|
m_clientOptions.Configuration = config;
|
|
m_configuration = config;
|
|
}
|
|
|
|
private async Task<EndpointDescription> SelectEndpointAsync(
|
|
string endpointUrl,
|
|
string? securityMode,
|
|
string? securityPolicy,
|
|
string authType,
|
|
CancellationToken ct)
|
|
{
|
|
bool hasFilter = securityMode != null || securityPolicy != null;
|
|
|
|
if (!hasFilter)
|
|
{
|
|
// Auto-select most secure, fall back to no-security
|
|
return (await CoreClientUtils.SelectEndpointAsync(
|
|
m_configuration!,
|
|
endpointUrl,
|
|
true,
|
|
Telemetry,
|
|
ct: ct).ConfigureAwait(false) ??
|
|
await CoreClientUtils.SelectEndpointAsync(
|
|
m_configuration!,
|
|
endpointUrl,
|
|
false,
|
|
Telemetry,
|
|
ct: ct).ConfigureAwait(false)) ??
|
|
throw new ServiceResultException(
|
|
StatusCodes.BadNotFound,
|
|
"No endpoints found at the specified URL.");
|
|
}
|
|
|
|
ArrayOf<EndpointDescription> allEndpoints =
|
|
await DiscoverEndpointsAsync(endpointUrl, ct).ConfigureAwait(false);
|
|
|
|
IEnumerable<EndpointDescription> candidates = allEndpoints.ToArray() ??
|
|
[];
|
|
|
|
// Filter to match the same transport scheme as the requested URL
|
|
var requestUri = new Uri(endpointUrl);
|
|
candidates = candidates.Where(ep =>
|
|
ep.EndpointUrl != null &&
|
|
ep.EndpointUrl.StartsWith(requestUri.Scheme, StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (securityMode != null)
|
|
{
|
|
MessageSecurityMode mode = ParseSecurityMode(securityMode);
|
|
candidates = candidates.Where(ep => ep.SecurityMode == mode);
|
|
}
|
|
|
|
if (securityPolicy != null)
|
|
{
|
|
candidates = candidates.Where(ep =>
|
|
ep.SecurityPolicyUri != null &&
|
|
ep.SecurityPolicyUri.EndsWith(
|
|
"#" + securityPolicy, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
// Filter by auth compatibility
|
|
UserTokenType requiredTokenType = ParseAuthTokenType(authType);
|
|
candidates = candidates.Where(ep =>
|
|
(ep.UserIdentityTokens.ToArray() ?? [])
|
|
.Any(t => t.TokenType == requiredTokenType));
|
|
|
|
return candidates
|
|
.OrderByDescending(ep => ep.SecurityLevel)
|
|
.FirstOrDefault() ??
|
|
throw new ServiceResultException(
|
|
StatusCodes.BadNotFound,
|
|
string.Format(
|
|
CultureInfo.InvariantCulture,
|
|
"No endpoint matches the specified criteria: securityMode={0}, " +
|
|
"securityPolicy={1}, authType={2}. " +
|
|
"Use GetEndpoints to see available configurations.",
|
|
securityMode ?? "(any)",
|
|
securityPolicy ?? "(any)",
|
|
authType));
|
|
}
|
|
|
|
private static MessageSecurityMode ParseSecurityMode(string securityMode)
|
|
{
|
|
return securityMode.ToUpperInvariant() switch
|
|
{
|
|
"NONE" => MessageSecurityMode.None,
|
|
"SIGN" => MessageSecurityMode.Sign,
|
|
"SIGNANDENCRYPT" => MessageSecurityMode.SignAndEncrypt,
|
|
_ => throw new ArgumentException(
|
|
$"Invalid securityMode '{securityMode}'. " +
|
|
"Expected 'None', 'Sign', or 'SignAndEncrypt'.",
|
|
nameof(securityMode))
|
|
};
|
|
}
|
|
|
|
private static UserTokenType ParseAuthTokenType(string authType)
|
|
{
|
|
return authType.ToUpperInvariant() switch
|
|
{
|
|
"ANONYMOUS" => UserTokenType.Anonymous,
|
|
"USERNAME" => UserTokenType.UserName,
|
|
"CERTIFICATE" => UserTokenType.Certificate,
|
|
_ => throw new ArgumentException(
|
|
$"Invalid authType '{authType}'. " +
|
|
"Expected 'Anonymous', 'Username', or 'Certificate'.",
|
|
nameof(authType))
|
|
};
|
|
}
|
|
|
|
private static UserIdentity BuildUserIdentity(
|
|
string authType,
|
|
string? username,
|
|
string? password)
|
|
{
|
|
switch (authType.ToUpperInvariant())
|
|
{
|
|
case "ANONYMOUS":
|
|
return new UserIdentity();
|
|
case "USERNAME":
|
|
if (string.IsNullOrEmpty(username))
|
|
{
|
|
throw new ArgumentException(
|
|
"Username is required for 'Username' authentication.",
|
|
nameof(username));
|
|
}
|
|
return new UserIdentity(
|
|
username,
|
|
System.Text.Encoding.UTF8.GetBytes(password ?? string.Empty));
|
|
case "CERTIFICATE":
|
|
throw new NotSupportedException(
|
|
"Certificate authentication is not yet supported through the MCP " +
|
|
"Connect tool. Certificate auth requires certificate store " +
|
|
"configuration which is beyond the scope of MCP tool parameters. " +
|
|
"Use 'Anonymous' or 'Username' authentication instead.");
|
|
default:
|
|
throw new ArgumentException(
|
|
$"Invalid authType '{authType}'. " +
|
|
"Expected 'Anonymous', 'Username', or 'Certificate'.",
|
|
nameof(authType));
|
|
}
|
|
}
|
|
|
|
private void SessionConnectionStateChanged(string name, ConnectionStateChangedEventArgs e)
|
|
{
|
|
if (e.Error != null && ServiceResult.IsNotGood(e.Error))
|
|
{
|
|
m_logger.LogWarning(
|
|
"Session '{Name}' state changed from {PreviousState} to {NewState}. " +
|
|
"ReconnectAttempt={ReconnectAttempt}, Status={Status}",
|
|
name,
|
|
e.PreviousState,
|
|
e.NewState,
|
|
e.ReconnectAttempt,
|
|
e.Error);
|
|
return;
|
|
}
|
|
|
|
m_logger.LogInformation(
|
|
"Session '{Name}' state changed from {PreviousState} to {NewState}. " +
|
|
"ReconnectAttempt={ReconnectAttempt}",
|
|
name,
|
|
e.PreviousState,
|
|
e.NewState,
|
|
e.ReconnectAttempt);
|
|
}
|
|
|
|
private void SessionChannelStateChanged(string name, ChannelStateChange e)
|
|
{
|
|
if (e.Error != null && ServiceResult.IsNotGood(e.Error))
|
|
{
|
|
m_logger.LogWarning(
|
|
"Session '{Name}' channel state changed from {PreviousState} to {NewState}. " +
|
|
"ReconnectAttempt={ReconnectAttempt}, Status={Status}",
|
|
name,
|
|
e.PreviousState,
|
|
e.NewState,
|
|
e.ReconnectAttempt,
|
|
e.Error);
|
|
return;
|
|
}
|
|
|
|
m_logger.LogInformation(
|
|
"Session '{Name}' channel state changed from {PreviousState} to {NewState}. " +
|
|
"ReconnectAttempt={ReconnectAttempt}",
|
|
name,
|
|
e.PreviousState,
|
|
e.NewState,
|
|
e.ReconnectAttempt);
|
|
}
|
|
|
|
private static string FormatSessionStatus(SessionInfo info)
|
|
{
|
|
if (!info.IsConnected)
|
|
{
|
|
return string.Format(
|
|
CultureInfo.InvariantCulture,
|
|
"'{0}': Disconnected (was {1})",
|
|
info.Name,
|
|
info.Endpoint.EndpointUrl);
|
|
}
|
|
|
|
return string.Format(
|
|
CultureInfo.InvariantCulture,
|
|
"'{0}': Connected to {1}. SessionName={2}, SessionId={3}, ServerUri={4}",
|
|
info.Name,
|
|
info.Endpoint.EndpointUrl,
|
|
info.Session.SessionName,
|
|
info.Session.SessionId,
|
|
info.Session.ServerUris?.ToArray().FirstOrDefault() ?? "unknown");
|
|
}
|
|
|
|
private static bool AutoAcceptError(
|
|
Certificate certificate,
|
|
ServiceResult error)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
}
|
|
}
|