mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
Fix recycling scenario loading and MCP interoperability
This commit is contained in:
@@ -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 `--`.
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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"`.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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++)
|
||||
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user