/* ======================================================================== * 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 { /// /// MCP tools for exporting OPC UA server address spaces to NodeSet2 XML format. /// [McpServerToolType] public sealed class NodeSetExportTools { private const int kMaxBrowseDepth = 128; /// /// Export the address space of a connected OPC UA server to NodeSet2 XML. /// [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 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 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 { ["success"] = true, ["filePath"] = filePath, ["nodeCount"] = nodes.Count, ["fileSizeBytes"] = fileSize, ["exportMode"] = exportMode, ["durationMs"] = stopwatch.ElapsedMilliseconds, ["namespaces"] = session.NamespaceUris.ToArray() .Select((uri, idx) => new Dictionary { ["index"] = idx, ["uri"] = uri }).ToList() }); } catch (ServiceResultException ex) { return OpcUaJsonHelper.Serialize(new Dictionary { ["error"] = true, ["statusCode"] = ex.StatusCode.ToString(), ["message"] = ex.Message }); } } /// /// Export the address space split into separate NodeSet2 XML files per namespace. /// [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 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 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? targetSet = namespaceFilter is { Length: > 0 } ? new HashSet(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)[.. g]); var exportedFiles = new List>(); foreach (KeyValuePair> 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 { ["namespaceUri"] = nsUri, ["namespaceIndex"] = kvp.Key, ["filePath"] = filePath, ["nodeCount"] = kvp.Value.Count, ["fileSizeBytes"] = new FileInfo(filePath).Length }); } stopwatch.Stop(); return OpcUaJsonHelper.Serialize(new Dictionary { ["success"] = true, ["outputDirectory"] = outputDirectory, ["totalNodeCount"] = nodes.Count, ["exportedNamespaces"] = exportedFiles.Count, ["durationMs"] = stopwatch.ElapsedMilliseconds, ["files"] = exportedFiles }); } catch (ServiceResultException ex) { return OpcUaJsonHelper.Serialize(new Dictionary { ["error"] = true, ["statusCode"] = ex.StatusCode.ToString(), ["message"] = ex.Message }); } } /// /// Recursively fetches all nodes from the server starting at the given node /// using the session's NodeCache. /// private static async Task> FetchAllNodesAsync( ISession session, NodeId startingNode, bool addRootNode, bool filterBaseTypes, CancellationToken ct) { var nodeDictionary = new Dictionary(); ArrayOf referenceTypes = [ReferenceTypeIds.HierarchicalReferences]; ArrayOf 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 response = await session.NodeCache .FindReferencesAsync(nodesToBrowse, referenceTypes, false, true, ct) .ConfigureAwait(false); var nextNodesToBrowse = new List(); 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]; } /// /// Pre-loads reference type hierarchy into the NodeCache. /// private static async Task FetchReferenceIdTypesAsync( ISession session, CancellationToken ct) { ArrayOf referenceNodes = [ReferenceTypeIds.References]; ArrayOf referenceTypes = [ReferenceTypeIds.HasSubtype]; for (int depth = 0; depth < 10; depth++) { ArrayOf 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(); } } /// /// Creates a safe filename from a namespace URI. /// 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"; } } }