Fix recycling scenario loading and MCP interoperability

This commit is contained in:
Marc
2026-10-15 12:58:13 +02:00
committed by GitHub
parent 1b7db89f26
commit 09a54fcc08
19 changed files with 633 additions and 42 deletions
+33
View File
@@ -31,6 +31,8 @@ to the owning package's `NugetREADME.md`, linked from each catalogue below.
- [Option 3: Run from source](#option-3-run-from-source)
- [Option 4: Install from local build](#option-4-install-from-local-build)
- [Configuration](#configuration)
- [Certificate user identities](#certificate-user-identities)
- [Typed method arguments and structured values](#typed-method-arguments-and-structured-values)
- [Run on demand without a global installation](#run-on-demand-without-a-global-installation)
- [Claude Desktop](#claude-desktop)
- [VS Code / GitHub Copilot](#vs-code--github-copilot)
@@ -681,6 +683,37 @@ explicitly.
## Configuration
### Certificate user identities
The host can provision named X.509 user identities in
`McpServer:CertificateIdentities:<name>`. Each entry selects an existing certificate
store using `StoreType`, `StorePath`, and exactly one of `SubjectName` or `Thumbprint`.
For example, a local host can set:
```powershell
$env:McpServer__CertificateIdentities__observer__StoreType = 'Directory'
$env:McpServer__CertificateIdentities__observer__StorePath = 'D:\OpcUaIdentities\observer'
$env:McpServer__CertificateIdentities__observer__SubjectName = 'CN=PlantObserver'
```
Call `Connect` with `authType: "Certificate"` and `certificateIdentity: "observer"`.
Tool arguments cannot supply certificate-store paths, private keys or key passwords.
An embedding host can inject `ICertificatePasswordProvider` for protected keys.
Missing, unknown or ambiguous references fail rather than falling back to another identity.
Application-certificate trust and server-side user authorization still apply independently;
keep `autoAcceptCerts: false`. Only expose configured identities to trusted MCP callers.
### Typed method arguments and structured values
For structured data, explicitly load the selected DataType closure with
`schema_load_types` before reading or calling methods. Generic reads and calls retain
structured fields through UA codecs. `Call` and `CallMethod` accept
`inputArgumentsJson`, an explicit OPC UA JSON Variant array, instead of legacy string
`inputArguments`. For example, an Int64 and Int32 pair is
`[{"UaType":8,"Value":"0"},{"UaType":6,"Value":1}]`: UA JSON represents Int64 values
as strings. A structure uses `UaType: 22` and a `Value` object containing `UaTypeId`
and its fields. The existing string-only argument behavior is unchanged.
### Run on demand without a global installation
Launch through `dotnet tool exec` or `dnx`, with tool arguments after `--`.
+5
View File
@@ -232,6 +232,11 @@ executable independently defaults to `healthy`, so another scenario must be pass
the coordinator and all ten views. For `--scenario <file.json>`, every role needs a readable
copy with identical effective settings; container copies are mounted read-only.
JSON scenario files override the documented top-level defaults: omitted fields retain
their defaults, while explicit zero values, nulls and unknown members still undergo
normal validation. The checked-in scenario files have the same effective settings as
their corresponding named scenarios.
Views compare the full configuration/model hash before membership or projection. It includes
residence, seconds per tick, source settings, fault schedules, leases and bounds—not just the
scenario name. A mismatch or missing identity reports `BadConfigurationError` and stops automatic
@@ -32,6 +32,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
@@ -76,10 +77,7 @@ namespace Opc.Ua.Recycling
FileStream file = OpenBounded(scenario);
await using (file.ConfigureAwait(false))
{
configuration = await JsonSerializer.DeserializeAsync(
file, RecyclingJsonContext.Default.RecyclingConfiguration, cancellationToken)
.ConfigureAwait(false)
?? throw new ArgumentException("Scenario document is null.");
configuration = await LoadScenarioAsync(file, cancellationToken).ConfigureAwait(false);
}
}
else
@@ -124,6 +122,32 @@ namespace Opc.Ua.Recycling
return options;
}
/// <summary>
/// Applies explicit scenario fields over the immutable defaults before source-generated deserialization.
/// </summary>
/// <remarks>
/// Generated init-only constructors otherwise replace omitted properties with CLR defaults.
/// Explicit nulls, zero values and unknown members are retained for normal validation.
/// </remarks>
internal static async ValueTask<RecyclingConfiguration> LoadScenarioAsync(
Stream stream, CancellationToken cancellationToken = default)
{
JsonNode? supplied = await JsonNode.ParseAsync(stream, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (supplied is not JsonObject overrides)
{
throw new JsonException("A scenario must be a non-null JSON object.");
}
JsonObject effective = JsonSerializer.SerializeToNode(
new RecyclingConfiguration(), RecyclingJsonContext.Default.RecyclingConfiguration)!.AsObject();
foreach (KeyValuePair<string, JsonNode?> property in overrides)
{
effective[property.Key] = property.Value?.DeepClone();
}
return effective.Deserialize(RecyclingJsonContext.Default.RecyclingConfiguration)
?? throw new JsonException("Scenario document is null.");
}
private static RecyclingProcess ParseProcess(string value)
{
return value switch
@@ -23,4 +23,8 @@
<!-- Native executable smoke remains a net10.0 profile; managed protocol tests run on all three targets. -->
<Compile Remove="NativeRecyclingSmokeTests.cs" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\samples\Recycling\RecyclingServer\scenarios\*.json"
Link="Scenarios\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -28,6 +28,10 @@
* ======================================================================*/
using System;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using NUnit.Framework;
namespace Opc.Ua.Recycling.Samples.Tests
@@ -38,6 +42,67 @@ namespace Opc.Ua.Recycling.Samples.Tests
[TestFixture]
public sealed class RecyclingCommandLineTests
{
/// <summary>
/// The shipped sparse JSON recipes must retain the same effective defaults as their named counterparts.
/// </summary>
[TestCase("healthy")]
[TestCase("sorter-drift")]
[TestCase("utility-disturbance")]
[TestCase("equipment-faults")]
[TestCase("combined")]
[TestCase("recovery")]
public async Task ShippedScenarioFilesMatchNamedDefaultsAsync(string scenario)
{
string json = await File.ReadAllTextAsync(Path.Combine(
TestContext.CurrentContext.TestDirectory, "Scenarios", scenario + ".json")).ConfigureAwait(false);
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
RecyclingConfiguration actual = await RecyclingCommandLine.LoadScenarioAsync(stream).ConfigureAwait(false);
RecyclingConfiguration expected = RecyclingConfiguration.ForScenario(scenario);
actual.Validate();
Assert.That(JsonSerializer.Serialize(actual, RecyclingJsonContext.Default.RecyclingConfiguration),
Is.EqualTo(JsonSerializer.Serialize(expected, RecyclingJsonContext.Default.RecyclingConfiguration)));
}
/// <summary>
/// Explicit zero settings are preserved and invalid values are not replaced by valid defaults.
/// </summary>
[TestCase(false)]
[TestCase(true)]
public async Task ScenarioOverridesPreserveExplicitValuesAsync(bool invalid)
{
string json = invalid ? "{\"tickSeconds\":0}" : "{\"plannedSourceBatches\":0,\"thermalSupplyWatts\":0}";
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
RecyclingConfiguration configuration = await RecyclingCommandLine.LoadScenarioAsync(stream)
.ConfigureAwait(false);
if (invalid)
{
Assert.That(configuration.TickSeconds, Is.Zero);
Assert.Throws<ArgumentException>(configuration.Validate);
}
else
{
configuration.Validate();
Assert.That(configuration.PlannedSourceBatches, Is.Zero);
Assert.That(configuration.ThermalSupplyWatts, Is.Zero);
Assert.That(configuration.SourceBatch.Total,
Is.EqualTo(new RecyclingConfiguration().SourceBatch.Total));
}
}
/// <summary>
/// A sparse scenario still rejects unknown members and malformed or null collections.
/// </summary>
[TestCase("{\"typo\":1}")]
[TestCase("{\"stages\":null}")]
[TestCase("null")]
[TestCase("[]")]
public async Task ScenarioOverridesRejectInvalidDocumentsAsync(string json)
{
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
Assert.ThrowsAsync<JsonException>(() => RecyclingCommandLine.LoadScenarioAsync(stream).AsTask());
}
/// <summary>
/// An absent selector is an argument error, with or without unrelated valid options.
/// </summary>
@@ -249,6 +249,67 @@ namespace Opc.Ua.Tools.Tests.Mcp
Assert.That(OpcUaJsonHelper.VariantToObject(default), Is.Null);
}
/// <summary>
/// Generic reads preserve structured fields rather than returning only a CLR type name.
/// </summary>
[Test]
public void StructuredValuesRetainTheirFields()
{
var argument = new Argument
{
Name = "Command",
DataType = DataTypeIds.String,
ValueRank = ValueRanks.Scalar
};
string text = OpcUaJsonHelper.Serialize(OpcUaJsonHelper.VariantToObject(Variant.FromStructure(argument)));
using JsonDocument document = JsonDocument.Parse(text);
Assert.That(document.RootElement.GetProperty("body").ValueKind, Is.EqualTo(JsonValueKind.Object));
Assert.That(document.RootElement.GetProperty("body").GetProperty("Name").GetString(),
Is.EqualTo("Command"));
Assert.That(document.RootElement.GetProperty("body").GetProperty("ValueRank").GetInt32(),
Is.EqualTo(ValueRanks.Scalar));
}
/// <summary>
/// Explicit UA JSON inputs round-trip native scalar and structure types instead of becoming strings.
/// </summary>
[Test]
public void TypedCallArgumentsPreserveTypesAndLegacyStrings()
{
IServiceMessageContext context = ServiceMessageContext.Create(null!);
ArrayOf<Variant> expected =
[
Variant.From(0L), Variant.From(false),
Variant.FromStructure(new Argument { Name = "Input", DataType = DataTypeIds.UInt32 })
];
using var encoder = new JsonEncoder(context, JsonEncoderOptions.Verbose);
encoder.WriteVariantArray("Arguments", expected);
using JsonDocument document = JsonDocument.Parse(encoder.CloseAndReturnText());
ArrayOf<Variant> decoded = OpcUaJsonHelper.ParseCallArguments(
null, document.RootElement.GetProperty("Arguments").GetRawText(), context);
Assert.That(decoded, Is.EqualTo(expected));
ArrayOf<Variant> legacy = OpcUaJsonHelper.ParseCallArguments(["42", "false"], null, context);
Assert.That(legacy[0].TryGetValue(out string text), Is.True);
Assert.That(text, Is.EqualTo("42"));
Assert.That(legacy[1].TryGetValue(out string booleanText), Is.True);
Assert.That(booleanText, Is.EqualTo("false"));
}
/// <summary>
/// Malformed or ambiguous typed input is refused before a method is dispatched.
/// </summary>
[TestCase("{}", false)]
[TestCase("null", false)]
[TestCase("[", false)]
[TestCase("[]", true)]
public void TypedCallArgumentsRejectInvalidShapeOrConflictingInputs(string json, bool legacy)
{
Assert.Throws<ServiceResultException>(() => OpcUaJsonHelper.ParseCallArguments(
legacy ? ["text"] : null, json, ServiceMessageContext.Create(null!)));
}
[Test]
public void VariantToObjectReturnsBooleanAsIs()
{
@@ -210,8 +210,9 @@ namespace Opc.Ua.Tools.Tests.McpCompanion
/// <summary>
/// Client loading uses the real AOT-safe factory and never browses the BaseDataType tree or dictionaries.
/// </summary>
[Test]
public async Task ExplicitLoadingUsesFiniteDefaultFactoryAsync()
[TestCase(false)]
[TestCase(true)]
public async Task ExplicitLoadingUsesFiniteDefaultFactoryAsync(bool includeEnumeration)
{
await using var local = new SchemaTestContext();
await using RemoteModelTestSession remote =
@@ -221,6 +222,19 @@ namespace Opc.Ua.Tools.Tests.McpCompanion
NodeId encoding = remote.Id("RecordBinary");
StructureDefinition definition = SchemaTestContext.Structure(
SchemaTestContext.Field("Count", DataTypeIds.Int32));
if (includeEnumeration)
{
NodeId enumeration = remote.Id("Mode");
SetType(remote, enumeration, new QualifiedName("Mode", remote.InstanceNamespace), new EnumDefinition
{
Fields =
[
new EnumField { Name = "Stopped", Value = 0 },
new EnumField { Name = "Running", Value = 1 }
]
});
definition.Fields = definition.Fields.AddItem(SchemaTestContext.Field("Mode", enumeration));
}
definition.DefaultEncodingId = encoding;
SetType(remote, id, new QualifiedName("Record", remote.InstanceNamespace), definition);
remote.Browse(id, ReferenceTypeIds.HasEncoding,
@@ -30,10 +30,17 @@
#if NET10_0
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Opc.Ua.Client;
using Opc.Ua.Identity;
using Opc.Ua.Mcp;
using Opc.Ua.Security.Certificates;
@@ -42,6 +49,107 @@ namespace Opc.Ua.Tools.Tests
[TestFixture]
public class OpcUaSessionManagerTests
{
/// <summary>
/// A named host identity resolves through the real certificate store and standard token-policy selection.
/// </summary>
[Test]
public async Task CertificateIdentityUsesTheConfiguredStoreAndPolicyAsync()
{
using var telemetry = (DefaultTelemetry)DefaultTelemetry.Create(static _ => { });
string root = Path.Combine(
TestContext.CurrentContext.WorkDirectory, "mcp-cert-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
using Certificate certificate = CertificateBuilder.Create("CN=McpNamedUser")
.SetHashAlgorithm(HashAlgorithmName.SHA256)
.SetRSAKeySize(2048)
.CreateForRSA();
using (var store = new DirectoryCertificateStore(telemetry))
{
store.Open(root, noPrivateKeys: false);
await store.AddAsync(certificate).ConfigureAwait(false);
}
var configurationValues = new Dictionary<string, string?>
{
["McpServer:CertificateIdentities:operator:StoreType"] = CertificateStoreType.Directory,
["McpServer:CertificateIdentities:operator:StorePath"] = root,
["McpServer:CertificateIdentities:operator:Thumbprint"] = certificate.Thumbprint
};
IConfiguration host = new ConfigurationBuilder().AddInMemoryCollection(configurationValues).Build();
OpcUaMcpOptions options = McpHostBuilder.CreateOpcUaMcpOptions(host, "services");
var services = new ServiceCollection();
services.AddOpcUaMcpCore(options);
await using ServiceProvider provider = services.BuildServiceProvider();
OpcUaSessionManager sessions = provider.GetRequiredService<OpcUaSessionManager>();
ApplicationConfiguration configuration = CreateConfiguration(telemetry, out CertificateManager manager);
using (manager)
{
IClientIdentityProvider identityProvider =
sessions.CreateCertificateIdentityProvider("operator", configuration);
var endpoint = new EndpointDescription
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
UserIdentityTokens =
[
new UserTokenPolicy
{
PolicyId = "certificate-policy",
TokenType = UserTokenType.Certificate
}
]
};
IUserIdentity identity = await identityProvider.AcquireIdentityAsync(
endpoint, ServiceMessageContext.Create(telemetry)).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(options.CertificateIdentities.Count, Is.EqualTo(1));
Assert.That(identity.TokenType, Is.EqualTo(UserTokenType.Certificate));
Assert.That(identity.PolicyId, Is.EqualTo("certificate-policy"));
Assert.That(identity.TokenHandler, Is.InstanceOf<X509IdentityTokenHandler>());
});
}
}
finally
{
Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// Missing, unapproved and duplicate identity references never silently fall back to another user.
/// </summary>
[TestCase(null, false)]
[TestCase("", false)]
[TestCase("unconfigured", false)]
[TestCase("Operator", false)]
[TestCase("operator", true)]
public async Task CertificateIdentityReferencesFailClosedAsync(string? reference, bool duplicate)
{
using var telemetry = (DefaultTelemetry)DefaultTelemetry.Create(static _ => { });
var named = new McpCertificateIdentity("operator", new X509ClientIdentityOptions
{
StoreType = CertificateStoreType.Directory,
StorePath = "not-opened",
SubjectName = "CN=NotLoaded"
});
var options = new OpcUaMcpOptions { CertificateIdentities = duplicate ? [named, named] : [named] };
var services = new ServiceCollection();
services.AddOpcUaMcpCore(options);
await using ServiceProvider provider = services.BuildServiceProvider();
OpcUaSessionManager sessions = provider.GetRequiredService<OpcUaSessionManager>();
ApplicationConfiguration configuration = CreateConfiguration(telemetry, out CertificateManager manager);
using (manager)
{
ServiceResultException error = Assert.Throws<ServiceResultException>(() =>
sessions.CreateCertificateIdentityProvider(reference, configuration))!;
Assert.That(error.StatusCode, Is.EqualTo(
duplicate ? StatusCodes.BadConfigurationError : StatusCodes.BadIdentityTokenRejected));
Assert.That(sessions.GetAllSessions(), Is.Empty);
}
}
[Test]
public async Task ConcurrentAutoAcceptContextsUseIndependentCertificateManagersAsync()
{
+22
View File
@@ -63,6 +63,16 @@ session. For example, `ReadValue` accepts:
{ "nodeId": "ns=2;s=Temperature", "sessionName": "plant" }
```
Certificate authentication uses a non-secret `certificateIdentity` name configured
by the host, alongside `authType: "Certificate"`. The executable reads
`McpServer:CertificateIdentities:<name>` with `StoreType`, `StorePath`, and exactly
one of `SubjectName` or `Thumbprint`. Embedders populate
`OpcUaMcpOptions.CertificateIdentities` with `McpCertificateIdentity` entries and
can inject `ICertificatePasswordProvider` for protected keys. The existing
certificate store/provider and user-token policy selection perform authentication;
tool callers cannot supply private keys or arbitrary certificate-store locations.
Unknown references fail closed. Expose these identities only to trusted MCP clients.
NodeIds use OPC UA string notation:
| Kind | Example |
@@ -79,6 +89,18 @@ Views `i=87`, Server `i=2253` and ServerStatus.CurrentTime `i=2258`.
uses `objectId`, `methodId` and ordered `inputArguments`. Use the running
catalogue's schema for each service's typed request shape.
`Call` and `CallMethod` additionally accept `inputArgumentsJson` instead of the
legacy string array. It is an OPC UA JSON Variant array, for example
`[{"UaType":8,"Value":"0"},{"UaType":6,"Value":1}]` for Int64/Int32 arguments.
Structure values use `UaType: 22` and a `Value` object containing `UaTypeId` and
the structure's fields. Load custom types with `schema_load_types` first.
Conflicting argument forms and invalid JSON are rejected before dispatch.
Existing `inputArguments` strings are never implicitly reinterpreted as JSON.
Generic read/call results retain an ExtensionObject's `typeId` and decoded `body`
fields through UA codecs. Unregistered encoded bodies remain explicitly encoded;
they are not reduced to a type name or silently omitted.
UA JSON preserves types, quality and timestamps. Variant serialization uses
`BuiltInType`, so Boolean `false` and numeric zero do not become Variant.Null.
Non-finite companion measurements are `"NaN"`, `"Infinity"` or `"-Infinity"`.
+13
View File
@@ -29,6 +29,7 @@
using System;
using System.Globalization;
using Opc.Ua.Client;
namespace Opc.Ua.Mcp
{
@@ -114,6 +115,11 @@ namespace Opc.Ua.Mcp
/// </summary>
public McpExecutionOptions Execution { get; set; } = new();
/// <summary>
/// Gets or sets host-approved X.509 user identities selected by name, never by caller-supplied store paths.
/// </summary>
public ArrayOf<McpCertificateIdentity> CertificateIdentities { get; set; } = [];
/// <summary>
/// Creates options from the well-known environment variables the MCP
/// tools consume.
@@ -147,4 +153,11 @@ namespace Opc.Ua.Mcp
return options;
}
}
/// <summary>
/// A named user-certificate selector provisioned by the MCP host.
/// </summary>
/// <param name="Name">The exact non-secret reference accepted by Connect.</param>
/// <param name="Certificate">Store selection; private-key passwords stay with the injected provider.</param>
public sealed record McpCertificateIdentity(string Name, X509ClientIdentityOptions Certificate);
}
+90 -11
View File
@@ -39,6 +39,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Opc.Ua.Identity;
using Opc.Ua.Security.Certificates;
namespace Opc.Ua.Mcp
@@ -288,7 +289,7 @@ namespace Opc.Ua.Mcp
/// Connects to an OPC UA server with endpoint selection and authentication options.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public async Task<string> ConnectAsync(
public Task<string> ConnectAsync(
string? name,
string endpointUrl,
string? securityMode,
@@ -298,8 +299,34 @@ namespace Opc.Ua.Mcp
string? password,
bool autoAcceptCerts,
CancellationToken ct)
{
return ConnectAsync(name, endpointUrl, securityMode, securityPolicy, authType,
username, password, autoAcceptCerts, certificateIdentity: null, ct);
}
/// <summary>
/// Connects using the selected authentication kind and an optional host-approved X.509 identity reference.
/// </summary>
public async Task<string> ConnectAsync(
string? name,
string endpointUrl,
string? securityMode,
string? securityPolicy,
string authType,
string? username,
string? password,
bool autoAcceptCerts,
string? certificateIdentity,
CancellationToken ct)
{
ObjectDisposedException.ThrowIf(m_disposed, this);
if (certificateIdentity != null &&
!string.Equals(authType, "Certificate", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
"A certificate identity reference requires Certificate authentication.",
nameof(certificateIdentity));
}
await m_lock.WaitAsync(ct).ConfigureAwait(false);
try
@@ -323,6 +350,10 @@ namespace Opc.Ua.Mcp
}
await EnsureConfigurationInternalAsync(ct).ConfigureAwait(false);
IClientIdentityProvider? identityProvider =
string.Equals(authType, "Certificate", StringComparison.OrdinalIgnoreCase)
? CreateCertificateIdentityProvider(certificateIdentity, m_configuration!)
: null;
ConnectionValidationContext? validationContext =
await CreateConnectionValidationContextAsync(
m_configuration!,
@@ -343,15 +374,20 @@ namespace Opc.Ua.Mcp
authType,
ct).ConfigureAwait(false);
UserIdentity identity = BuildUserIdentity(authType, username, password);
var endpointConfiguration = EndpointConfiguration.Create(configuration);
var endpoint = new ConfiguredEndpoint(null, selectedEndpoint, endpointConfiguration);
ManagedSessionBuilder builder = new ManagedSessionBuilder(configuration, Telemetry)
.UseEndpoint(endpoint)
.WithSessionName(name)
.WithSessionTimeout(TimeSpan.FromMilliseconds(60_000))
.WithUserIdentity(identity);
.WithSessionTimeout(TimeSpan.FromMilliseconds(60_000));
if (identityProvider != null)
{
builder.WithIdentityProvider(identityProvider);
}
else
{
builder.WithUserIdentity(BuildUserIdentity(authType, username, password));
}
if (validationContext.UseSharedChannelManager)
{
@@ -552,6 +588,55 @@ namespace Opc.Ua.Mcp
m_lock.Dispose();
}
/// <summary>
/// Resolves a host-owned selector through the existing X.509 identity and certificate providers.
/// </summary>
internal IClientIdentityProvider CreateCertificateIdentityProvider(
string? reference,
ApplicationConfiguration configuration)
{
if (string.IsNullOrWhiteSpace(reference))
{
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected,
"Certificate authentication requires a host-configured certificateIdentity reference.");
}
ArrayOf<McpCertificateIdentity> identities =
m_serviceProvider.GetRequiredService<OpcUaMcpOptions>().CertificateIdentities;
McpCertificateIdentity? selected = null;
foreach (McpCertificateIdentity candidate in identities)
{
if (candidate.Name != reference)
{
continue;
}
if (selected != null)
{
throw new ServiceResultException(StatusCodes.BadConfigurationError,
"The configured certificate identity name is ambiguous.");
}
selected = candidate;
}
if (selected?.Certificate == null)
{
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected,
"The requested certificateIdentity is not configured by the host.");
}
ICertificateManager manager = configuration.CertificateManager ??
throw new ServiceResultException(StatusCodes.BadConfigurationError,
"The application certificate manager is not initialized.");
ICertificatePasswordProvider passwords = m_serviceProvider
.GetService<ICertificatePasswordProvider>() ?? new CertificatePasswordProvider();
return new CompositeClientIdentityProviderBuilder()
.AddX509(options =>
{
options.StoreType = selected.Certificate.StoreType;
options.StorePath = selected.Certificate.StorePath;
options.SubjectName = selected.Certificate.SubjectName;
options.Thumbprint = selected.Certificate.Thumbprint;
}, manager.CertificateProvider, passwords)
.Build();
}
private static async Task DisconnectInternalAsync(SessionInfo info, CancellationToken ct)
{
if (!info.OwnsSession)
@@ -804,12 +889,6 @@ namespace Opc.Ua.Mcp
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}'. " +
@@ -317,6 +317,83 @@ namespace Opc.Ua.Mcp.Serialization
}
}
/// <summary>
/// Preserves structured session values through their UA codecs without changing primitive result shapes.
/// </summary>
internal static object? VariantToObject(Variant variant, IServiceMessageContext context)
{
if (variant.TypeInfo.BuiltInType == BuiltInType.ExtensionObject)
{
if (variant.TryGetValue(out ExtensionObject scalar))
{
return ExtensionObjectToDict(scalar, context);
}
if (variant.TryGetValue(out ArrayOf<ExtensionObject> array))
{
return array.IsNull ? null :
array.ToArray()!.Select(value => ExtensionObjectToDict(value, context)).ToList();
}
return McpCompanionJson.Variant(variant, context);
}
if (variant.TypeInfo.BuiltInType is BuiltInType.DataValue or BuiltInType.Variant)
{
return McpCompanionJson.Variant(variant, context);
}
return VariantToObject(variant);
}
/// <summary>
/// Preserves session-specific structure codecs alongside the ordinary quality and timestamp fields.
/// </summary>
internal static Dictionary<string, object?> DataValueToDict(
in DataValue value, IServiceMessageContext context)
{
Dictionary<string, object?> result = DataValueToDict(value.WithWrappedValue(Variant.Null));
result["value"] = VariantToObject(value.WrappedValue, context);
return result;
}
/// <summary>
/// Decodes explicitly typed UA JSON arguments without reinterpreting legacy string arguments.
/// </summary>
internal static ArrayOf<Variant> ParseCallArguments(
string[]? inputArguments,
string? inputArgumentsJson,
IServiceMessageContext context)
{
if (inputArgumentsJson == null)
{
return inputArguments == null ? [] : inputArguments.Select(value => new Variant(value)).ToArrayOf();
}
if (inputArguments != null)
{
throw new ServiceResultException(StatusCodes.BadInvalidArgument,
"Specify either inputArguments or inputArgumentsJson, not both.");
}
if (inputArgumentsJson.Length > 4 * 1024 * 1024)
{
throw new ServiceResultException(StatusCodes.BadEncodingLimitsExceeded,
"Typed method arguments exceed the 4 MiB input bound.");
}
JsonNode? parsed;
try
{
parsed = JsonNode.Parse(inputArgumentsJson);
}
catch (JsonException exception)
{
throw ServiceResultException.Create(
StatusCodes.BadDecodingError, exception, "Typed method arguments must be a UA JSON Variant array.");
}
if (parsed is not JsonArray array || array.Count > 1024)
{
throw new ServiceResultException(StatusCodes.BadInvalidArgument,
"Typed method arguments must be a UA JSON Variant array of at most 1024 items.");
}
using var decoder = new JsonDecoder(new JsonObject { ["Arguments"] = array }.ToJsonString(), context);
return decoder.ReadVariantArray("Arguments");
}
private static object? VariantArrayToObject(Variant variant)
{
return variant.TypeInfo.BuiltInType switch
@@ -550,16 +627,23 @@ namespace Opc.Ua.Mcp.Serialization
};
}
private static Dictionary<string, object?> ExtensionObjectToDict(ExtensionObject ext)
private static Dictionary<string, object?> ExtensionObjectToDict(
ExtensionObject ext, IServiceMessageContext? context = null)
{
var result = new Dictionary<string, object?>
{
["typeId"] = ext.TypeId.ToString()
};
if (ext.TryGetValue(out IEncodeable? encodeable))
context ??= ServiceMessageContext.Create(null!);
if (ext.TryGetValue(out IEncodeable? encodeable, context))
{
result["body"] = encodeable.ToString();
result["body"] = McpCompanionJson.Encode(encodeable, context);
}
else if (!ext.IsNull)
{
result["encoding"] = ext.Encoding.ToString();
result["body"] = McpCompanionJson.Variant(Variant.From(ext), context);
}
return result;
@@ -88,7 +88,7 @@ namespace Opc.Ua.Mcp.Tools
results.Add(new Dictionary<string, object?>
{
["nodeId"] = nodeIds[i],
["result"] = OpcUaJsonHelper.DataValueToDict(response.Results[i])
["result"] = OpcUaJsonHelper.DataValueToDict(response.Results[i], session.MessageContext)
});
}
@@ -224,7 +224,7 @@ namespace Opc.Ua.Mcp.Tools
if (histResult.HistoryData.TryGetValue(out HistoryData? historyData))
{
resultDict["dataValues"] = historyData.DataValues
.ToArray()!.Select(OpcUaJsonHelper.DataValueToDict)
.ToArray()!.Select(value => OpcUaJsonHelper.DataValueToDict(value, session.MessageContext))
.ToList();
}
+25 -2
View File
@@ -110,11 +110,32 @@ namespace Opc.Ua.Mcp.Tools
/// <summary>
/// Connect to an OPC UA server.
/// </summary>
public static Task<string> ConnectAsync(
OpcUaSessionManager sessionManager,
string endpointUrl,
string? securityMode,
string? securityPolicy,
string authType,
string? username,
string? password,
bool autoAcceptCerts,
string? name,
CancellationToken ct)
{
return ConnectAsync(sessionManager, endpointUrl, securityMode, securityPolicy,
authType, username, password, autoAcceptCerts, name, certificateIdentity: null, ct);
}
/// <summary>
/// Connects with optional named, host-provisioned X.509 user-certificate authentication.
/// </summary>
[McpServerTool(Name = "Connect")]
[Description("Connect to an OPC UA server. For simplest usage, provide just the endpointUrl " +
"and it will auto-select the most secure endpoint with anonymous authentication. " +
"Use GetEndpoints first to discover available security configurations and auth methods, " +
"then specify securityMode/securityPolicy to select a specific endpoint. Returns a human-readable " +
"then specify securityMode/securityPolicy to select a specific endpoint. Certificate authentication " +
"requires a host-configured certificateIdentity name; no private key or store path is accepted. " +
"Returns a human-readable " +
"confirmation string with the resolved session name, security mode/policy, and session ID; on " +
"failure returns JSON {error:true, statusCode, message}.")]
public static async Task<string> ConnectAsync(
@@ -129,13 +150,15 @@ namespace Opc.Ua.Mcp.Tools
[Description("Password for 'Username' authentication")] string? password = null,
[Description("Auto-accept untrusted server certificates (for testing only, default: false)")] bool autoAcceptCerts = false,
[Description("Session name (auto-generated from endpoint URL if omitted)")] string? name = null,
[Description("Exact host-configured X.509 user identity name; required for Certificate authentication. " +
"Not a certificate, password, or file path.")] string? certificateIdentity = null,
CancellationToken ct = default)
{
try
{
return await sessionManager.ConnectAsync(
name, endpointUrl, securityMode, securityPolicy,
authType, username, password, autoAcceptCerts, ct)
authType, username, password, autoAcceptCerts, certificateIdentity, ct)
.ConfigureAwait(false);
}
catch (ServiceResultException ex)
+23 -12
View File
@@ -72,7 +72,7 @@ namespace Opc.Ua.Mcp.Tools
return OpcUaJsonHelper.Serialize(new Dictionary<string, object?>
{
["nodeId"] = nodeId,
["value"] = OpcUaJsonHelper.VariantToObject(dataValue.WrappedValue),
["value"] = OpcUaJsonHelper.VariantToObject(dataValue.WrappedValue, session.MessageContext),
["statusCode"] = OpcUaJsonHelper.StatusCodeToString(dataValue.StatusCode),
["sourceTimestamp"] = dataValue.SourceTimestamp != DateTime.MinValue
? dataValue.SourceTimestamp.ToString("o", System.Globalization.CultureInfo.InvariantCulture)
@@ -120,7 +120,7 @@ namespace Opc.Ua.Mcp.Tools
results.Add(new Dictionary<string, object?>
{
["nodeId"] = nodeIds[i],
["value"] = OpcUaJsonHelper.VariantToObject(dataValues[i].WrappedValue),
["value"] = OpcUaJsonHelper.VariantToObject(dataValues[i].WrappedValue, session.MessageContext),
["statusCode"] = OpcUaJsonHelper.StatusCodeToString(dataValues[i].StatusCode),
["serviceResult"] = OpcUaJsonHelper.StatusCodeToString(serviceResults[i].StatusCode)
});
@@ -301,6 +301,20 @@ namespace Opc.Ua.Mcp.Tools
/// <summary>
/// Call a method with simplified parameters.
/// </summary>
public static Task<string> CallMethodAsync(
OpcUaSessionManager sessionManager,
string objectId,
string methodId,
string[]? inputArguments,
string? sessionName,
CancellationToken ct)
{
return CallMethodAsync(sessionManager, objectId, methodId, inputArguments, sessionName, null, ct);
}
/// <summary>
/// Calls a method with explicit UA JSON types when non-string input arguments are required.
/// </summary>
[McpServerTool(Name = "CallMethod")]
[Description("Call a method on an OPC UA server with simplified parameters. Returns output argument values.")]
public static async Task<string> CallMethodAsync(
@@ -309,6 +323,9 @@ namespace Opc.Ua.Mcp.Tools
[Description("Method node ID to call")] string methodId,
[Description("Input argument values as strings (optional)")] string[]? inputArguments = null,
[Description("Session name to use (defaults to the only active session)")] string? sessionName = null,
[Description("Optional UA JSON Variant array, e.g. [{\"UaType\":6,\"Value\":0}]. " +
"Use instead of inputArguments for numbers or structures; load custom types first.")]
string? inputArgumentsJson = null,
CancellationToken ct = default)
{
OpcUaSessionManager.SessionInfo sessionInfo = sessionManager.GetSessionInfoOrThrow(sessionName);
@@ -319,20 +336,14 @@ namespace Opc.Ua.Mcp.Tools
await using ConfiguredAsyncDisposable lifetime = operation.ConfigureAwait(false);
sessionInfo = operation.Access;
ISession session = sessionInfo.Session;
var inputArgs = new List<Variant>();
if (inputArguments != null)
{
foreach (string arg in inputArguments)
{
inputArgs.Add(new Variant(arg));
}
}
ArrayOf<Variant> inputArgs = OpcUaJsonHelper.ParseCallArguments(
inputArguments, inputArgumentsJson, session.MessageContext);
var request = new CallMethodRequest
{
ObjectId = OpcUaJsonHelper.ParseNodeId(objectId),
MethodId = OpcUaJsonHelper.ParseNodeId(methodId),
InputArguments = inputArgs.ToArray()
InputArguments = inputArgs
};
await sessionManager.UaOperationGuard.AuthorizeCallsAsync(sessionInfo, [request], ct)
@@ -363,7 +374,7 @@ namespace Opc.Ua.Mcp.Tools
["methodId"] = methodId,
["outputArguments"] = result.OutputArguments
.ToArray()?
.Select(v => OpcUaJsonHelper.VariantToObject(v))
.Select(v => OpcUaJsonHelper.VariantToObject(v, session.MessageContext))
.ToList() ??
[]
});
@@ -47,6 +47,20 @@ namespace Opc.Ua.Mcp.Tools
/// <summary>
/// Call one or more methods on the OPC UA server.
/// </summary>
public static Task<string> CallAsync(
OpcUaSessionManager sessionManager,
string objectId,
string methodId,
string[]? inputArguments,
string? sessionName,
CancellationToken ct)
{
return CallAsync(sessionManager, objectId, methodId, inputArguments, sessionName, null, ct);
}
/// <summary>
/// Calls a method with legacy strings or explicitly typed UA JSON arguments.
/// </summary>
[McpServerTool(Name = "Call")]
[Description("Call one or more methods on the OPC UA server. Each method call specifies the object and " +
"method NodeIds plus input arguments. Returns JSON with responseHeader, statusCode, outputArguments, " +
@@ -57,6 +71,9 @@ namespace Opc.Ua.Mcp.Tools
[Description("Method node ID to call")] string methodId,
[Description("Input argument values as strings (optional)")] string[]? inputArguments = null,
[Description("Session name to use (defaults to the only active session)")] string? sessionName = null,
[Description("Optional UA JSON Variant array, e.g. [{\"UaType\":6,\"Value\":0}]. " +
"Use instead of inputArguments for numbers or structures; load custom types first.")]
string? inputArgumentsJson = null,
CancellationToken ct = default)
{
OpcUaSessionManager.SessionInfo sessionInfo = sessionManager.GetSessionInfoOrThrow(sessionName);
@@ -68,9 +85,8 @@ namespace Opc.Ua.Mcp.Tools
await using ConfiguredAsyncDisposable lifetime = operation.ConfigureAwait(false);
sessionInfo = operation.Access;
Client.ISession session = sessionInfo.Session;
ArrayOf<Variant> inputArgs = inputArguments != null
? inputArguments.Select(arg => new Variant(arg)).ToArray()
: [];
ArrayOf<Variant> inputArgs = OpcUaJsonHelper.ParseCallArguments(
inputArguments, inputArgumentsJson, session.MessageContext);
ArrayOf<CallMethodRequest> methodsToCall =
[
@@ -93,7 +109,8 @@ namespace Opc.Ua.Mcp.Tools
CallMethodResult result = response.Results[0];
List<object?> outputArgs = result.OutputArguments.IsNull
? []
: [.. result.OutputArguments.ToArray()!.Select(v => OpcUaJsonHelper.VariantToObject(v))];
: [.. result.OutputArguments.ToArray()!.Select(
v => OpcUaJsonHelper.VariantToObject(v, session.MessageContext))];
return OpcUaJsonHelper.Serialize(new Dictionary<string, object?>
{
@@ -360,7 +360,8 @@ namespace Opc.Ua.Mcp.Tools
{
["nodeId"] = ds.NodeId.ToString(),
["typeDefinitionNode"] = ds.TypeDefinitionNode.ToString(),
["values"] = ds.Values.IsNull ? null : ds.Values.ToArray()!.Select(v => OpcUaJsonHelper.VariantToObject(v)).ToList()
["values"] = ds.Values.IsNull ? null : ds.Values.ToArray()!.Select(
v => OpcUaJsonHelper.VariantToObject(v, session.MessageContext)).ToList()
})];
return OpcUaJsonHelper.Serialize(new Dictionary<string, object?>
@@ -412,7 +413,8 @@ namespace Opc.Ua.Mcp.Tools
{
["nodeId"] = ds.NodeId.ToString(),
["typeDefinitionNode"] = ds.TypeDefinitionNode.ToString(),
["values"] = ds.Values.IsNull ? null : ds.Values.ToArray()!.Select(v => OpcUaJsonHelper.VariantToObject(v)).ToList()
["values"] = ds.Values.IsNull ? null : ds.Values.ToArray()!.Select(
v => OpcUaJsonHelper.VariantToObject(v, session.MessageContext)).ToList()
})];
return OpcUaJsonHelper.Serialize(new Dictionary<string, object?>
@@ -229,6 +229,21 @@ namespace Opc.Ua.Mcp.Tools
{
DisableDataTypeDictionary = true
};
ArrayOf<UaTypeDescription> dependencies = snapshot.Types;
for (int index = 0; index < dependencies.Count; index++)
{
UaTypeDescription dependency = dependencies[index];
if (dependency.Definition is EnumDefinition && !dependency.IsStructureOptionSet)
{
IType? enumeration = await loader.LoadTypeAsync(
dependency.TypeId, subTypes: false, throwOnError: true, token).ConfigureAwait(false);
if (enumeration == null)
{
throw SchemaDefinitionSnapshot.Unsupported(
"An enumeration in the selected type closure could not be registered.");
}
}
}
var results = new JsonArray();
bool error = false;
for (int index = 0; index < roots.Count; index++)
+11
View File
@@ -29,6 +29,7 @@
using System;
using System.Globalization;
using System.Linq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -110,6 +111,16 @@ namespace Opc.Ua.Mcp
ArgumentNullException.ThrowIfNull(configuration);
OpcUaMcpOptions options = CreateOpcUaMcpOptions();
options.CertificateIdentities = configuration.GetSection("McpServer:CertificateIdentities")
.GetChildren()
.Select(identity => new McpCertificateIdentity(identity.Key, new Opc.Ua.Client.X509ClientIdentityOptions
{
StoreType = identity["StoreType"] ?? string.Empty,
StorePath = identity["StorePath"] ?? string.Empty,
SubjectName = identity["SubjectName"],
Thumbprint = identity["Thumbprint"]
}))
.ToArrayOf();
options.Execution = McpExecutionConfiguration.Read(configuration);
options.TransferRoot = configuration["McpServer:TransferRoot"] ?? options.TransferRoot;
string? configuredLimit = configuration["McpServer:MaxTransferBytes"];