mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 08:41:51 +08:00
370 lines
16 KiB
C#
370 lines
16 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.ComponentModel;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using ModelContextProtocol.Server;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Mcp.Serialization;
|
|
|
|
using ISession = Opc.Ua.Client.ISession;
|
|
|
|
namespace Opc.Ua.Mcp.Tools
|
|
{
|
|
/// <summary>
|
|
/// MCP tools for exporting OPC UA server address spaces to NodeSet2 XML format.
|
|
/// </summary>
|
|
[McpServerToolType]
|
|
public sealed class NodeSetExportTools
|
|
{
|
|
private const int kMaxBrowseDepth = 128;
|
|
|
|
/// <summary>
|
|
/// Export the address space of a connected OPC UA server to NodeSet2 XML.
|
|
/// </summary>
|
|
[McpServerTool(Name = "ExportNodeSet")]
|
|
[Description("Export the address space of a connected OPC UA server to a NodeSet2 XML file. " +
|
|
"Browses recursively from a starting node, collects all nodes, and writes them as " +
|
|
"standard NodeSet2 XML. The file can be used for documentation, analysis, or import " +
|
|
"into other tools. Returns the file path and node count.")]
|
|
public static async Task<string> ExportNodeSetAsync(
|
|
OpcUaSessionManager sessionManager,
|
|
[Description("File path to write the NodeSet2 XML to (e.g. 'C:\\export\\server.xml'). " +
|
|
"Directory will be created if it doesn't exist.")] string filePath,
|
|
[Description("Starting node ID for the export (default: 'i=84' = Root). " +
|
|
"Use 'i=85' for Objects folder only.")] string startingNodeId = "i=84",
|
|
[Description("Export mode: 'Default' (schema-only, no values) or 'Complete' " +
|
|
"(includes runtime values and user context). Default: 'Default'")] string exportMode = "Default",
|
|
[Description("Whether to include the starting node itself (default: true)")] bool includeStartNode = true,
|
|
[Description("Whether to filter out OPC UA base type nodes from namespace 0 " +
|
|
"(default: false — include all)")] bool filterBaseTypes = false,
|
|
[Description("Session name to use (defaults to the only active session)")] string? sessionName = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
ISession session = sessionManager.GetSessionOrThrow(sessionName);
|
|
|
|
try
|
|
{
|
|
var stopwatch = Stopwatch.StartNew();
|
|
|
|
// Collect all nodes via NodeCache
|
|
IList<INode> nodes = await FetchAllNodesAsync(
|
|
session, OpcUaJsonHelper.ParseNodeId(startingNodeId),
|
|
includeStartNode, filterBaseTypes, ct).ConfigureAwait(false);
|
|
|
|
NodeSetExportOptions options = exportMode.Equals(
|
|
"Complete", StringComparison.OrdinalIgnoreCase)
|
|
? NodeSetExportOptions.Complete
|
|
: NodeSetExportOptions.Default;
|
|
|
|
// Ensure directory exists
|
|
string? directory = Path.GetDirectoryName(filePath);
|
|
if (!string.IsNullOrEmpty(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
// Export
|
|
using var outputStream = new FileStream(filePath, FileMode.Create);
|
|
var systemContext = new SystemContext(sessionManager.Telemetry)
|
|
{
|
|
NamespaceUris = session.NamespaceUris,
|
|
ServerUris = session.ServerUris
|
|
};
|
|
|
|
CoreClientUtils.ExportNodesToNodeSet2(
|
|
systemContext, nodes, outputStream, options);
|
|
|
|
stopwatch.Stop();
|
|
long fileSize = new FileInfo(filePath).Length;
|
|
|
|
return OpcUaJsonHelper.Serialize(new Dictionary<string, object?>
|
|
{
|
|
["success"] = true,
|
|
["filePath"] = filePath,
|
|
["nodeCount"] = nodes.Count,
|
|
["fileSizeBytes"] = fileSize,
|
|
["exportMode"] = exportMode,
|
|
["durationMs"] = stopwatch.ElapsedMilliseconds,
|
|
["namespaces"] = session.NamespaceUris.ToArray()
|
|
.Select((uri, idx) => new Dictionary<string, object?>
|
|
{
|
|
["index"] = idx,
|
|
["uri"] = uri
|
|
}).ToList()
|
|
});
|
|
}
|
|
catch (ServiceResultException ex)
|
|
{
|
|
return OpcUaJsonHelper.Serialize(new Dictionary<string, object?>
|
|
{
|
|
["error"] = true,
|
|
["statusCode"] = ex.StatusCode.ToString(),
|
|
["message"] = ex.Message
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Export the address space split into separate NodeSet2 XML files per namespace.
|
|
/// </summary>
|
|
[McpServerTool(Name = "ExportNodeSetPerNamespace")]
|
|
[Description("Export the address space split into separate NodeSet2 XML files, one per namespace. " +
|
|
"Useful for exporting companion specifications individually. Skips the OPC UA base " +
|
|
"namespace (ns=0) by default. Returns a list of exported files.")]
|
|
public static async Task<string> ExportNodeSetPerNamespaceAsync(
|
|
OpcUaSessionManager sessionManager,
|
|
[Description("Output directory where NodeSet2 XML files will be created")] string outputDirectory,
|
|
[Description("Starting node ID (default: 'i=84' = Root)")] string startingNodeId = "i=84",
|
|
[Description("Optional list of namespace URIs to include. If omitted, all non-base " +
|
|
"namespaces are exported.")] string[]? namespaceFilter = null,
|
|
[Description("Export mode: 'Default' or 'Complete' (default: 'Default')")] string exportMode = "Default",
|
|
[Description("Session name to use (defaults to the only active session)")] string? sessionName = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
ISession session = sessionManager.GetSessionOrThrow(sessionName);
|
|
|
|
try
|
|
{
|
|
var stopwatch = Stopwatch.StartNew();
|
|
|
|
// Collect all nodes
|
|
IList<INode> nodes = await FetchAllNodesAsync(
|
|
session, OpcUaJsonHelper.ParseNodeId(startingNodeId),
|
|
true, false, ct).ConfigureAwait(false);
|
|
|
|
NodeSetExportOptions options = exportMode.Equals(
|
|
"Complete", StringComparison.OrdinalIgnoreCase)
|
|
? NodeSetExportOptions.Complete
|
|
: NodeSetExportOptions.Default;
|
|
|
|
Directory.CreateDirectory(outputDirectory);
|
|
|
|
// Build namespace filter set
|
|
HashSet<string>? targetSet = namespaceFilter is { Length: > 0 }
|
|
? new HashSet<string>(namespaceFilter, StringComparer.OrdinalIgnoreCase)
|
|
: null;
|
|
|
|
// Group nodes by namespace
|
|
var nodesByNamespace = nodes
|
|
.Where(node => node.NodeId.NamespaceIndex > 0)
|
|
.GroupBy(node => node.NodeId.NamespaceIndex)
|
|
.Where(group =>
|
|
{
|
|
string? nsUri = session.NamespaceUris.GetString(group.Key);
|
|
if (string.IsNullOrEmpty(nsUri))
|
|
{
|
|
return false;
|
|
}
|
|
if (targetSet != null)
|
|
{
|
|
return targetSet.Contains(nsUri);
|
|
}
|
|
return !string.Equals(nsUri, Namespaces.OpcUa, StringComparison.OrdinalIgnoreCase);
|
|
})
|
|
.ToDictionary(g => g.Key, g => (IList<INode>)[.. g]);
|
|
|
|
var exportedFiles = new List<Dictionary<string, object?>>();
|
|
|
|
foreach (KeyValuePair<ushort, IList<INode>> kvp in nodesByNamespace)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
string? nsUri = session.NamespaceUris.GetString(kvp.Key);
|
|
string fileName = CreateSafeFileName(nsUri!, kvp.Key);
|
|
string filePath = Path.Combine(outputDirectory, fileName);
|
|
|
|
using var outputStream = new FileStream(filePath, FileMode.Create);
|
|
var systemContext = new SystemContext(sessionManager.Telemetry)
|
|
{
|
|
NamespaceUris = session.NamespaceUris,
|
|
ServerUris = session.ServerUris
|
|
};
|
|
|
|
CoreClientUtils.ExportNodesToNodeSet2(
|
|
systemContext, kvp.Value, outputStream, options);
|
|
|
|
exportedFiles.Add(new Dictionary<string, object?>
|
|
{
|
|
["namespaceUri"] = nsUri,
|
|
["namespaceIndex"] = kvp.Key,
|
|
["filePath"] = filePath,
|
|
["nodeCount"] = kvp.Value.Count,
|
|
["fileSizeBytes"] = new FileInfo(filePath).Length
|
|
});
|
|
}
|
|
|
|
stopwatch.Stop();
|
|
|
|
return OpcUaJsonHelper.Serialize(new Dictionary<string, object?>
|
|
{
|
|
["success"] = true,
|
|
["outputDirectory"] = outputDirectory,
|
|
["totalNodeCount"] = nodes.Count,
|
|
["exportedNamespaces"] = exportedFiles.Count,
|
|
["durationMs"] = stopwatch.ElapsedMilliseconds,
|
|
["files"] = exportedFiles
|
|
});
|
|
}
|
|
catch (ServiceResultException ex)
|
|
{
|
|
return OpcUaJsonHelper.Serialize(new Dictionary<string, object?>
|
|
{
|
|
["error"] = true,
|
|
["statusCode"] = ex.StatusCode.ToString(),
|
|
["message"] = ex.Message
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recursively fetches all nodes from the server starting at the given node
|
|
/// using the session's NodeCache.
|
|
/// </summary>
|
|
private static async Task<IList<INode>> FetchAllNodesAsync(
|
|
ISession session,
|
|
NodeId startingNode,
|
|
bool addRootNode,
|
|
bool filterBaseTypes,
|
|
CancellationToken ct)
|
|
{
|
|
var nodeDictionary = new Dictionary<ExpandedNodeId, INode>();
|
|
ArrayOf<NodeId> referenceTypes = [ReferenceTypeIds.HierarchicalReferences];
|
|
ArrayOf<ExpandedNodeId> nodesToBrowse = [startingNode];
|
|
|
|
// Clear NodeCache to ensure fresh fetch from server
|
|
session.NodeCache.Clear();
|
|
|
|
// Pre-populate reference types
|
|
await FetchReferenceIdTypesAsync(session, ct).ConfigureAwait(false);
|
|
|
|
if (addRootNode)
|
|
{
|
|
INode? rootNode = await session.NodeCache.FindAsync(startingNode, ct)
|
|
.ConfigureAwait(false);
|
|
if (rootNode != null)
|
|
{
|
|
nodeDictionary[rootNode.NodeId] = rootNode;
|
|
}
|
|
}
|
|
|
|
int depth = 0;
|
|
while (nodesToBrowse.Count > 0 && depth < kMaxBrowseDepth)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
depth++;
|
|
|
|
ArrayOf<INode> response = await session.NodeCache
|
|
.FindReferencesAsync(nodesToBrowse, referenceTypes, false, true, ct)
|
|
.ConfigureAwait(false);
|
|
|
|
var nextNodesToBrowse = new List<ExpandedNodeId>();
|
|
foreach (INode node in response)
|
|
{
|
|
if (!nodeDictionary.ContainsKey(node.NodeId))
|
|
{
|
|
if (filterBaseTypes && node.NodeId.NamespaceIndex == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
nodeDictionary[node.NodeId] = node;
|
|
nextNodesToBrowse.Add(node.NodeId);
|
|
}
|
|
}
|
|
|
|
nodesToBrowse = nextNodesToBrowse.ToArray();
|
|
}
|
|
|
|
return [.. nodeDictionary.Values];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pre-loads reference type hierarchy into the NodeCache.
|
|
/// </summary>
|
|
private static async Task FetchReferenceIdTypesAsync(
|
|
ISession session, CancellationToken ct)
|
|
{
|
|
ArrayOf<ExpandedNodeId> referenceNodes = [ReferenceTypeIds.References];
|
|
ArrayOf<NodeId> referenceTypes = [ReferenceTypeIds.HasSubtype];
|
|
|
|
for (int depth = 0; depth < 10; depth++)
|
|
{
|
|
ArrayOf<INode> response = await session.NodeCache
|
|
.FindReferencesAsync(referenceNodes, referenceTypes, false, true, ct)
|
|
.ConfigureAwait(false);
|
|
|
|
if (response.Count == 0)
|
|
{
|
|
break;
|
|
}
|
|
|
|
referenceNodes = response.ToArray()!
|
|
.Select(n => n.NodeId)
|
|
.ToArray();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a safe filename from a namespace URI.
|
|
/// </summary>
|
|
private static string CreateSafeFileName(string namespaceUri, ushort namespaceIndex)
|
|
{
|
|
string fileName = namespaceUri
|
|
.Replace("http://", string.Empty, StringComparison.OrdinalIgnoreCase)
|
|
.Replace("https://", string.Empty, StringComparison.OrdinalIgnoreCase)
|
|
.Replace("urn:", string.Empty, StringComparison.OrdinalIgnoreCase);
|
|
|
|
foreach (char c in Path.GetInvalidFileNameChars())
|
|
{
|
|
fileName = fileName.Replace(c, '_');
|
|
}
|
|
|
|
fileName = fileName
|
|
.Replace('/', '_')
|
|
.Replace('\\', '_')
|
|
.Replace(':', '_')
|
|
.TrimEnd('_');
|
|
|
|
if (fileName.Length > 200)
|
|
{
|
|
fileName = fileName[..200];
|
|
}
|
|
|
|
return $"{fileName}_ns{namespaceIndex}.xml";
|
|
}
|
|
}
|
|
}
|