Files
Marc Schier be50f02700 Add additional companion spec MCP tool profiles (#4731)
# 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.
2026-10-14 00:14:05 +02:00

349 lines
17 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.Text;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Opc.Ua.Client;
namespace Opc.Ua.Mcp
{
/// <summary>
/// Converts a closed, AOT-safe set of typed workflow inputs before invoking any remote mutation.
/// </summary>
internal static class WorkflowValues
{
/// <summary>
/// Bounds complete typed batches before allocating UA update payloads or sending any operation.
/// </summary>
/// <typeparam name="T">A source-generated workflow input type.</typeparam>
/// <exception cref="InvalidOperationException"></exception>
internal static void ValidateInline<T>(T value, WorkflowOptions options)
where T : class
{
ArgumentNullException.ThrowIfNull(value);
JsonTypeInfo<T> contract = WorkflowInputJsonContext.Default.GetTypeInfo(typeof(T)) as JsonTypeInfo<T> ??
throw new InvalidOperationException("The request type has no generated JSON contract.");
using var buffer = new McpBoundedMemoryStream(options.MaxInlineBytes);
JsonSerializer.Serialize(buffer, value, contract);
}
/// <summary>
/// Parses built-in scalar and one-dimensional values without boxed Variant access.
/// </summary>
/// <exception cref="ArgumentException"></exception>
internal static Variant Parse(ISession session, WorkflowValueInput input, WorkflowOptions options)
{
ArgumentNullException.ThrowIfNull(input);
if (Encoding.UTF8.GetByteCount(input.Value.GetRawText()) > options.MaxInlineBytes)
{
throw new ArgumentException("The typed value exceeds the host's inline byte ceiling.");
}
JsonElement value = input.Value;
if (value.ValueKind == JsonValueKind.Array)
{
ArgumentOutOfRangeException.ThrowIfGreaterThan(value.GetArrayLength(), options.MaxItems);
return ParseArray(session, input.Type, [.. value.EnumerateArray()], options);
}
return input.Type switch
{
BuiltInType.Null when value.ValueKind == JsonValueKind.Null => Variant.Null,
BuiltInType.Boolean => Variant.From(value.GetBoolean()),
BuiltInType.SByte => Variant.From(value.GetSByte()),
BuiltInType.Byte => Variant.From(value.GetByte()),
BuiltInType.Int16 => Variant.From(value.GetInt16()),
BuiltInType.UInt16 => Variant.From(value.GetUInt16()),
BuiltInType.Int32 => Variant.From(value.GetInt32()),
BuiltInType.UInt32 => Variant.From(value.GetUInt32()),
BuiltInType.Int64 => Variant.From(value.GetInt64()),
BuiltInType.UInt64 => Variant.From(value.GetUInt64()),
BuiltInType.Float => Variant.From((float)Number(value)),
BuiltInType.Double => Variant.From(Number(value)),
BuiltInType.String => Variant.From(String(value)),
BuiltInType.DateTime => Variant.From((DateTimeUtc)WorkflowJson.Utc(String(value))),
BuiltInType.Guid => Variant.From(new Uuid(Guid.Parse(String(value)))),
BuiltInType.ByteString => Variant.From(Bytes(String(value), options.MaxInlineBytes)),
BuiltInType.NodeId => Variant.From(WorkflowJson.Node(session, String(value))),
BuiltInType.StatusCode => Variant.From(new StatusCode(value.GetUInt32())),
BuiltInType.LocalizedText => Variant.From(new LocalizedText(String(value))),
_ => throw new ArgumentException("The scalar type is not supported by this typed contract.")
};
}
/// <summary>
/// Assembles explicitly indexed array values without accepting nested or JSON-encoded payloads.
/// </summary>
/// <exception cref="ArgumentException"></exception>
internal static Dictionary<int, JsonElement[]> Arrays(
int valueCount, Func<int, int?> length, ArrayOf<WorkflowArrayElementInput> elements,
WorkflowOptions options)
{
ArgumentOutOfRangeException.ThrowIfGreaterThan(elements.Count, options.MaxItems);
int total = 0;
var sizes = new Dictionary<int, int>();
for (int index = 0; index < valueCount; index++)
{
if (length(index) is not { } size)
{
continue;
}
ArgumentOutOfRangeException.ThrowIfNegative(size);
ArgumentOutOfRangeException.ThrowIfGreaterThan(size, options.MaxItems);
if (size > options.MaxItems - total)
{
throw new ArgumentException("The total array-element count exceeds the host's item ceiling.");
}
sizes.Add(index, size);
total += size;
}
if (total != elements.Count)
{
throw new ArgumentException("arrayElements must exactly fill every declared arrayLength.");
}
var arrays = new Dictionary<int, JsonElement[]>();
foreach ((int index, int size) in sizes)
{
arrays.Add(index, new JsonElement[size]);
}
foreach (WorkflowArrayElementInput element in elements)
{
ArgumentNullException.ThrowIfNull(element);
if (!arrays.TryGetValue(element.ValueIndex, out JsonElement[]? array) ||
element.ElementIndex < 0 ||
element.ElementIndex >= array.Length ||
array[element.ElementIndex].ValueKind != JsonValueKind.Undefined)
{
throw new ArgumentException("An array element has an invalid or duplicate value/element index.");
}
Scalar(element.Value);
array[element.ElementIndex] = element.Value;
}
return arrays;
}
/// <summary>
/// Interprets one flat declaration using its discriminator and, when present, indexed array elements.
/// </summary>
/// <exception cref="ArgumentException"></exception>
internal static Variant Flat(
ISession session, BuiltInType type, JsonElement value, int index,
Dictionary<int, JsonElement[]> arrays, WorkflowOptions options)
{
if (arrays.TryGetValue(index, out JsonElement[]? elements))
{
if (value.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null)
{
throw new ArgumentException("A declared array uses arrayElements, not a scalar value.");
}
return ParseArray(session, type, elements, options);
}
Scalar(value);
return Parse(session, new WorkflowValueInput { Type = type, Value = value }, options);
}
/// <summary>
/// Decodes a bounded base64 identifier before allocating decoded storage.
/// </summary>
/// <exception cref="ArgumentException"></exception>
internal static ByteString Bytes(string text, int maximum)
{
WorkflowJson.Text(text, checked((maximum + 2) / 3 * 4), empty: true);
byte[] bytes = Convert.FromBase64String(text);
if (bytes.Length > maximum)
{
throw new ArgumentException("The decoded byte string exceeds its host ceiling.");
}
return ByteString.From(bytes);
}
/// <summary>
/// Converts exact event field selectors, preserving namespace URIs.
/// </summary>
/// <exception cref="ArgumentException"></exception>
internal static EventFilter Filter(
ISession session, ArrayOf<WorkflowEventFieldInput> fields, ArrayOf<WorkflowEventPathInput> paths = default)
{
ArgumentOutOfRangeException.ThrowIfLessThan(fields.Count, 1);
ArgumentOutOfRangeException.ThrowIfGreaterThan(fields.Count, 32);
ArgumentOutOfRangeException.ThrowIfGreaterThan(paths.Count, 256);
var segments = new Dictionary<int, Dictionary<int, QualifiedName>>();
foreach (WorkflowEventPathInput part in paths)
{
ArgumentNullException.ThrowIfNull(part);
if (part.SelectIndex < 0 ||
part.SelectIndex >= fields.Count ||
part.SegmentIndex < 0 ||
part.SegmentIndex >= 8)
{
throw new ArgumentException("An event path index is outside its selector or eight-segment limit.");
}
WorkflowJson.Text(part.Name, 256);
WorkflowJson.Text(part.NamespaceUri);
if (!segments.TryGetValue(part.SelectIndex, out Dictionary<int, QualifiedName>? selected))
{
selected = [];
segments.Add(part.SelectIndex, selected);
}
if (!selected.TryAdd(part.SegmentIndex, McpModelAccess.Name(session, part.NamespaceUri, part.Name)))
{
throw new ArgumentException("Event path segment indexes must be unique within each selector.");
}
}
var clauses = new SimpleAttributeOperand[fields.Count];
for (int index = 0; index < clauses.Length; index++)
{
WorkflowEventFieldInput input = fields[index];
ArgumentNullException.ThrowIfNull(input);
if (input.AttributeId is not Attributes.Value and not Attributes.NodeId)
{
throw new ArgumentException("An event field selects only Value or NodeId.");
}
ArrayOf<QualifiedName> path;
if (input.BrowseName is not null)
{
if (segments.ContainsKey(index))
{
throw new ArgumentException("Use a single browseName or indexed paths, not both.");
}
WorkflowJson.Text(input.BrowseName, 256);
string uri = input.NamespaceUri ?? Namespaces.OpcUa;
WorkflowJson.Text(uri);
path = [McpModelAccess.Name(session, uri, input.BrowseName)];
}
else
{
if (input.NamespaceUri is not null)
{
throw new ArgumentException("A selector namespaceUri requires its inline browseName.");
}
if (!segments.TryGetValue(index, out Dictionary<int, QualifiedName>? selected))
{
path = [];
}
else
{
var ordered = new QualifiedName[selected.Count];
for (int part = 0; part < ordered.Length; part++)
{
if (!selected.TryGetValue(part, out ordered[part]))
{
throw new ArgumentException("Event path segments must be contiguous from zero.");
}
}
path = ordered.ToArrayOf();
}
}
clauses[index] = new SimpleAttributeOperand
{
TypeDefinitionId = WorkflowJson.Node(session, input.TypeDefinitionId),
BrowsePath = path,
AttributeId = input.AttributeId
};
}
return new EventFilter { SelectClauses = clauses.ToArrayOf() };
}
/// <summary>
/// Refuses hidden objects and nested arrays where the interface promises one actual JSON scalar.
/// </summary>
/// <exception cref="ArgumentException"></exception>
private static void Scalar(JsonElement value)
{
if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Object or JsonValueKind.Array)
{
throw new ArgumentException("A field value must be an actual JSON scalar, not an object or array.");
}
}
/// <summary>
/// Converts a bounded scalar array using the declared UA element type.
/// </summary>
/// <exception cref="ArgumentException"></exception>
private static Variant ParseArray(
ISession session, BuiltInType type, JsonElement[] values, WorkflowOptions options)
{
return type switch
{
BuiltInType.Boolean => Variant.From(values.Select(element => element.GetBoolean()).ToArrayOf()),
BuiltInType.SByte => Variant.From(values.Select(element => element.GetSByte()).ToArrayOf()),
BuiltInType.Byte => Variant.From(values.Select(element => element.GetByte()).ToArrayOf()),
BuiltInType.Int16 => Variant.From(values.Select(element => element.GetInt16()).ToArrayOf()),
BuiltInType.UInt16 => Variant.From(values.Select(element => element.GetUInt16()).ToArrayOf()),
BuiltInType.Int32 => Variant.From(values.Select(element => element.GetInt32()).ToArrayOf()),
BuiltInType.UInt32 => Variant.From(values.Select(element => element.GetUInt32()).ToArrayOf()),
BuiltInType.Int64 => Variant.From(values.Select(element => element.GetInt64()).ToArrayOf()),
BuiltInType.UInt64 => Variant.From(values.Select(element => element.GetUInt64()).ToArrayOf()),
BuiltInType.Float => Variant.From(values.Select(element => (float)Number(element)).ToArrayOf()),
BuiltInType.Double => Variant.From(values.Select(Number).ToArrayOf()),
BuiltInType.String => Variant.From(values.Select(String).ToArrayOf()),
BuiltInType.DateTime => Variant.From(values.Select(element =>
(DateTimeUtc)WorkflowJson.Utc(String(element))).ToArrayOf()),
BuiltInType.NodeId => Variant.From(values.Select(element =>
WorkflowJson.Node(session, String(element))).ToArrayOf()),
BuiltInType.ByteString => Variant.From(values.Select(element =>
Bytes(String(element), options.MaxInlineBytes)).ToArrayOf()),
BuiltInType.Guid => Variant.From(values.Select(element =>
new Uuid(Guid.Parse(String(element)))).ToArrayOf()),
BuiltInType.StatusCode => Variant.From(values.Select(element =>
new StatusCode(element.GetUInt32())).ToArrayOf()),
BuiltInType.LocalizedText => Variant.From(values.Select(element =>
new LocalizedText(String(element))).ToArrayOf()),
_ => throw new ArgumentException("The array element type is not supported by this typed contract.")
};
}
/// <summary>
/// Requires a JSON string rather than silently coercing another value kind.
/// </summary>
/// <exception cref="ArgumentException"></exception>
private static string String(JsonElement value)
{
return value.GetString() ?? throw new ArgumentException("A non-null string value is required.");
}
/// <summary>
/// Accepts the standard UA JSON tokens for non-finite IEEE values, but not general string coercion.
/// </summary>
/// <exception cref="ArgumentException"></exception>
private static double Number(JsonElement value)
{
return value.ValueKind == JsonValueKind.String ? value.GetString() switch
{
"NaN" => double.NaN,
"Infinity" => double.PositiveInfinity,
"-Infinity" => double.NegativeInfinity,
_ => throw new ArgumentException("A floating value is a JSON number or NaN/Infinity/-Infinity.")
} : value.GetDouble();
}
}
}