diff --git a/tests/Opc.Ua.Tools.Tests/Mcp/Core/MethodServiceToolsTests.cs b/tests/Opc.Ua.Tools.Tests/Mcp/Core/MethodServiceToolsTests.cs
index c113407b05..317c82ee46 100644
--- a/tests/Opc.Ua.Tools.Tests/Mcp/Core/MethodServiceToolsTests.cs
+++ b/tests/Opc.Ua.Tools.Tests/Mcp/Core/MethodServiceToolsTests.cs
@@ -35,6 +35,7 @@ using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua;
using Opc.Ua.Mcp.Tools;
+using Opc.Ua.Tools.Tests.McpAuthorization;
namespace Opc.Ua.Tools.Tests.Mcp
{
@@ -43,6 +44,36 @@ namespace Opc.Ua.Tools.Tests.Mcp
{
private const string kUnknownSessionName = "definitely-not-connected";
+ ///
+ /// Both public call tools reject malformed variant envelopes before any method reaches the transport.
+ ///
+ [TestCase(false, "[{\"Value\":42}]")]
+ [TestCase(true, "[{\"Value\":42}]")]
+ [TestCase(false, "[{\"UaTyp\":6,\"Value\":42}]")]
+ [TestCase(true, "[{\"UaTyp\":6,\"Value\":42}]")]
+ [TestCase(false, "[{\"uaType\":6,\"Value\":42}]")]
+ [TestCase(true, "[{\"uaType\":6,\"Value\":42}]")]
+ [TestCase(false, "[{\"UaType\":null,\"Value\":42}]")]
+ [TestCase(true, "[{\"UaType\":null,\"Value\":42}]")]
+ public async Task CallToolsRejectMissingTypesBeforeDispatchAsync(bool convenience, string arguments)
+ {
+ await using McpGenericGenerationTestContext fixture =
+ await McpGenericGenerationTestContext.CreateAsync().ConfigureAwait(false);
+
+ string json = convenience
+ ? await ConvenienceTools.CallMethodAsync(
+ fixture.Manager, "ns=1;i=100", "ns=1;i=101",
+ inputArgumentsJson: arguments).ConfigureAwait(false)
+ : await MethodServiceTools.CallAsync(
+ fixture.Manager, "ns=1;i=100", "ns=1;i=101",
+ inputArgumentsJson: arguments).ConfigureAwait(false);
+
+ Assert.That(fixture.Mutations, Is.Empty);
+ using JsonDocument document = JsonDocument.Parse(json);
+ Assert.That(document.RootElement.GetProperty("error").GetBoolean(), Is.True);
+ Assert.That(document.RootElement.GetProperty("statusCode").GetString(), Is.EqualTo("BadDecodingError"));
+ }
+
[Test]
public async Task CallAsyncWithValidSessionReturnsStatusJsonAsync()
{
diff --git a/tests/Opc.Ua.Tools.Tests/Mcp/Core/OpcUaJsonHelperTests.cs b/tests/Opc.Ua.Tools.Tests/Mcp/Core/OpcUaJsonHelperTests.cs
index 1434ebc9c3..d9d9a297db 100644
--- a/tests/Opc.Ua.Tools.Tests/Mcp/Core/OpcUaJsonHelperTests.cs
+++ b/tests/Opc.Ua.Tools.Tests/Mcp/Core/OpcUaJsonHelperTests.cs
@@ -31,6 +31,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
+using System.Linq;
using System.Text;
using System.Text.Json;
using NUnit.Framework;
@@ -310,6 +311,101 @@ namespace Opc.Ua.Tools.Tests.Mcp
legacy ? ["text"] : null, json, ServiceMessageContext.Create(null!)));
}
+ ///
+ /// Multibyte arguments are bounded by their UTF-8 payload size, including the exact limit.
+ ///
+ [TestCase(-1)]
+ [TestCase(0)]
+ [TestCase(1)]
+ public void TypedCallArgumentsEnforceUtf8ByteLimit(int byteOffset)
+ {
+ const int maxBytes = 4 * 1024 * 1024;
+ string value = new('\u4E2D', 2700);
+ string item = "{\"UaType\":12,\"Value\":\"" + value + "\"}";
+ string prefix = "[" + string.Join(",", Enumerable.Repeat(item, 511)) + ",{\"UaType\":12,\"Value\":\"";
+ const string suffix = "\"}]";
+ int padding = maxBytes + byteOffset - Encoding.UTF8.GetByteCount(prefix) -
+ Encoding.UTF8.GetByteCount(value) - suffix.Length;
+ string finalValue = value + new string('x', padding);
+ string json = prefix + finalValue + suffix;
+ IServiceMessageContext context = ServiceMessageContext.Create(null!);
+
+ Assert.That(Encoding.UTF8.GetByteCount(json), Is.EqualTo(maxBytes + byteOffset));
+ Assert.That(json, Has.Length.LessThan(maxBytes));
+ if (byteOffset > 0)
+ {
+ ServiceResultException error = Assert.Throws(() =>
+ OpcUaJsonHelper.ParseCallArguments(null, json, context))!;
+ Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadEncodingLimitsExceeded));
+ }
+ else
+ {
+ ArrayOf decoded = OpcUaJsonHelper.ParseCallArguments(null, json, context);
+ Assert.That(decoded.Count, Is.EqualTo(512));
+ Assert.That(decoded[0].TryGetValue(out string first), Is.True);
+ Assert.That(first, Is.EqualTo(value));
+ Assert.That(decoded[^1].TryGetValue(out string last), Is.True);
+ Assert.That(last, Is.EqualTo(finalValue));
+ }
+ }
+
+ ///
+ /// Oversized multibyte input is refused before malformed JSON can reach the parser.
+ ///
+ [TestCase(false)]
+ [TestCase(true)]
+ public void TypedCallArgumentsRejectOversizeBeforeParsing(bool multibyte)
+ {
+ const int maxBytes = 4 * 1024 * 1024;
+ string json = multibyte ? new('\u4E2D', maxBytes / 3 + 1) : new('x', maxBytes + 1);
+
+ ServiceResultException error = Assert.Throws(() =>
+ OpcUaJsonHelper.ParseCallArguments(null, json, ServiceMessageContext.Create(null!)))!;
+
+ Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadEncodingLimitsExceeded));
+ }
+
+ ///
+ /// A non-null value without a usable type tag must not silently become Variant.Null.
+ ///
+ [TestCase("[{\"Value\":42}]")]
+ [TestCase("[{\"Value\":0}]")]
+ [TestCase("[{\"Value\":false}]")]
+ [TestCase("[{\"Value\":\"\"}]")]
+ [TestCase("[{\"Value\":[]}]")]
+ [TestCase("[{\"Value\":{}}]")]
+ [TestCase("[{\"UaTyp\":6,\"Value\":42}]")]
+ [TestCase("[{\"uaType\":6,\"Value\":42}]")]
+ [TestCase("[{\"UaType\":null,\"Value\":42}]")]
+ public void TypedCallArgumentsRejectMissingType(string json)
+ {
+ ServiceResultException error = Assert.Throws(() =>
+ OpcUaJsonHelper.ParseCallArguments(null, json, ServiceMessageContext.Create(null!)))!;
+
+ Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadDecodingError));
+ }
+
+ ///
+ /// Compact null encodings and omitted typed defaults remain valid alongside explicit zero and false.
+ ///
+ [Test]
+ public void TypedCallArgumentsPreserveNullAndTypedDefaults()
+ {
+ const string json =
+ "[null,{},{\"Value\":null},{\"UaType\":0},{\"UaType\":6}," +
+ "{\"UaType\":1,\"Value\":false},{\"UaType\":6,\"Value\":0}]";
+ ArrayOf expected =
+ [
+ Variant.Null, Variant.Null, Variant.Null, Variant.Null,
+ Variant.From(0), Variant.From(false), Variant.From(0)
+ ];
+
+ ArrayOf actual = OpcUaJsonHelper.ParseCallArguments(
+ null, json, ServiceMessageContext.Create(null!));
+
+ Assert.That(actual, Is.EqualTo(expected));
+ }
+
[Test]
public void VariantToObjectReturnsBooleanAsIs()
{
diff --git a/tools/Opc.Ua.Mcp.Core/NugetREADME.md b/tools/Opc.Ua.Mcp.Core/NugetREADME.md
index 07fc3adf19..468d80ede0 100644
--- a/tools/Opc.Ua.Mcp.Core/NugetREADME.md
+++ b/tools/Opc.Ua.Mcp.Core/NugetREADME.md
@@ -95,6 +95,9 @@ legacy string array. It is an OPC UA JSON Variant array, for example
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.
+Typed input is limited to 4 MiB of UTF-8 JSON and 1,024 arguments. A non-null
+`Value` requires an explicit `UaType`; a missing or misspelled type tag is not
+silently interpreted as a null argument. Null encodings and typed defaults remain supported.
Existing `inputArguments` strings are never implicitly reinterpreted as JSON.
Generic read/call results retain an ExtensionObject's `typeId` and decoded `body`
diff --git a/tools/Opc.Ua.Mcp.Core/Serialization/OpcUaJsonHelper.cs b/tools/Opc.Ua.Mcp.Core/Serialization/OpcUaJsonHelper.cs
index eac6a93b9d..c6629954e1 100644
--- a/tools/Opc.Ua.Mcp.Core/Serialization/OpcUaJsonHelper.cs
+++ b/tools/Opc.Ua.Mcp.Core/Serialization/OpcUaJsonHelper.cs
@@ -370,7 +370,9 @@ namespace Opc.Ua.Mcp.Serialization
throw new ServiceResultException(StatusCodes.BadInvalidArgument,
"Specify either inputArguments or inputArgumentsJson, not both.");
}
- if (inputArgumentsJson.Length > 4 * 1024 * 1024)
+ const int maxInputBytes = 4 * 1024 * 1024;
+ if (inputArgumentsJson.Length > maxInputBytes ||
+ Encoding.UTF8.GetByteCount(inputArgumentsJson) > maxInputBytes)
{
throw new ServiceResultException(StatusCodes.BadEncodingLimitsExceeded,
"Typed method arguments exceed the 4 MiB input bound.");
@@ -390,6 +392,14 @@ namespace Opc.Ua.Mcp.Serialization
throw new ServiceResultException(StatusCodes.BadInvalidArgument,
"Typed method arguments must be a UA JSON Variant array of at most 1024 items.");
}
+ foreach (JsonNode? argument in array)
+ {
+ if (argument is JsonObject envelope && envelope["Value"] != null && envelope["UaType"] == null)
+ {
+ throw new ServiceResultException(StatusCodes.BadDecodingError,
+ "A non-null typed method argument Value requires an explicit UaType.");
+ }
+ }
using var decoder = new JsonDecoder(new JsonObject { ["Arguments"] = array }.ToJsonString(), context);
return decoder.ReadVariantArray("Arguments");
}