/* ======================================================================== * 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 Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Server; using Opc.Ua.Client; using Opc.Ua.Mcp.Serialization; using ISession = Opc.Ua.Client.ISession; using OpcUaMcpServerOptions = Opc.Ua.Mcp.McpServerOptions; namespace Opc.Ua.Mcp.Tools { /// /// MCP tools for exporting OPC UA server address spaces to NodeSet2 XML format. /// /// /// All export paths are resolved against and constrained to the /// directory. The MCP server intentionally /// refuses to write outside that root so a malicious or accidental /// path (e.g. injected through the LLM tool argument) cannot /// overwrite arbitrary files. The root defaults to /// {TEMP}/Opc.Ua.Mcp/exports and can be overridden by setting /// the OPCUA_MCP_EXPORT_ROOT environment variable before the /// server is started. /// [McpServerToolType] public sealed class NodeSetExportTools { private const int kMaxBrowseDepth = 128; /// /// Environment variable that overrides the default export root. /// public const string ExportRootEnvironmentVariable = "OPCUA_MCP_EXPORT_ROOT"; /// /// The canonicalized base directory under which all NodeSet /// exports must live. See remarks on /// for how it is selected. /// public static string ExportRoot => s_exportRoot.Value; /// /// 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( IServiceProvider services, OpcUaSessionManager sessionManager, [Description("File path to write the NodeSet2 XML to. Relative paths are " + "resolved under the export root (see ExportRoot / " + "OPCUA_MCP_EXPORT_ROOT). Absolute paths must already be inside the " + "export root or the call is rejected.")] 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) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(sessionManager); ISession session = sessionManager.GetSessionOrThrow(sessionName); try { string resolvedFilePath = ResolveExportPath(services, filePath, nameof(filePath)); 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(resolvedFilePath); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } // Export using var outputStream = new FileStream(resolvedFilePath, 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(resolvedFilePath).Length; return OpcUaJsonHelper.Serialize(new Dictionary { ["success"] = true, ["filePath"] = resolvedFilePath, ["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( IServiceProvider services, OpcUaSessionManager sessionManager, [Description("Output directory where NodeSet2 XML files will be created. " + "Relative paths are resolved under the export root (see ExportRoot / " + "OPCUA_MCP_EXPORT_ROOT). Absolute paths must already be inside the " + "export root or the call is rejected.")] 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) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(sessionManager); ISession session = sessionManager.GetSessionOrThrow(sessionName); try { string resolvedOutputDirectory = ResolveExportPath( services, outputDirectory, nameof(outputDirectory)); 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(resolvedOutputDirectory); // 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(resolvedOutputDirectory, 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"; } /// /// Resolves a caller-supplied path and constrains it to /// . Relative paths are combined under /// the root; absolute paths are accepted only when they already /// resolve inside the root. Both forms are canonicalized via /// so directory-traversal /// sequences (..) cannot escape. /// /// The path supplied by the MCP tool caller. /// Tool parameter name used in error messages. /// An absolute path inside . /// /// is empty, white-space, or /// resolves outside . /// /// /// Resolves against the /// configured export root and rejects any path that would /// escape it. /// /// DI service provider used to look /// up . /// /// Caller-supplied path. /// Tool parameter name used in /// error messages. /// An absolute path inside the resolved export /// root. /// /// is empty, white-space, or /// resolves outside the export root. /// internal static string ResolveExportPath( IServiceProvider services, string requestedPath, string parameterName) { ArgumentNullException.ThrowIfNull(services); if (string.IsNullOrWhiteSpace(requestedPath)) { throw new ArgumentException( $"{parameterName} is required.", parameterName); } string root = ResolveExportRoot(services); string candidate = Path.IsPathRooted(requestedPath) ? Path.GetFullPath(requestedPath) : Path.GetFullPath(Path.Combine(root, requestedPath)); string relative = Path.GetRelativePath(root, candidate); bool escapes = relative == ".." || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) || Path.IsPathRooted(relative); if (escapes) { throw new ArgumentException( $"{parameterName} '{requestedPath}' resolves outside the export root '{root}'. " + $"Configure McpServerOptions.NodeSetExportRoot or set the " + $"{ExportRootEnvironmentVariable} environment variable to allow a different " + "base directory before starting the MCP server.", parameterName); } return candidate; } /// /// Resolves the active export-root directory using the /// precedence: /// from DI > OPCUA_MCP_EXPORT_ROOT environment /// variable > per-user default. /// internal static string ResolveExportRoot(IServiceProvider services) { ArgumentNullException.ThrowIfNull(services); OpcUaMcpServerOptions? mcpOptions = services.GetService(); if (mcpOptions is not null && !string.IsNullOrWhiteSpace(mcpOptions.NodeSetExportRoot)) { return Path.GetFullPath(mcpOptions.NodeSetExportRoot!); } return ExportRoot; } private static string InitializeExportRoot() { string? configured = Environment.GetEnvironmentVariable(ExportRootEnvironmentVariable); string root = !string.IsNullOrWhiteSpace(configured) ? configured! : Path.Combine(Path.GetTempPath(), "Opc.Ua.Mcp", "exports"); return Path.GetFullPath(root); } private static readonly Lazy s_exportRoot = new( InitializeExportRoot, LazyThreadSafetyMode.ExecutionAndPublication); } }