Add Machine Vision Parts 1 and 2 (#4733)

# Description

Implement the released [OPC
40100-1](https://reference.opcfoundation.org/specs/OPC-40100-1) and [OPC
40100-2](https://reference.opcfoundation.org/specs/OPC-40100-2)
specifications with generated models, provider-backed runtimes, typed
clients, MCP workflows, and DI/Machinery integration.

- **Part 1 stays in `Opc.Ua.Vision`** and its client/server packages
alongside the distinct Perception model. Provide control/acquisition
state machines, jobs and synchronized steps,
recipe/product/configuration/result management, file transfers,
diagnostics, events, and conditions.
- **Part 2 is in `Opc.Ua.MachineVision`, `.Client`, and `.Server`.**
Provide asset categories, identification, image-handling aspects,
health/maintenance data, topology, and transactional updates. The base
Vision assemblies do not reference these optional packages; the Part 2
wire model does not depend on Part 1.
- Preserve the release-pinned model bytes, wire namespaces and NodeIds.
Perception types are in `Opc.Ua.Vision.Perception`; the AMCM generated
namespace remains `Opc.Ua.Vision.MachineVision.AMCM`, and asset
client/server extension namespaces remain `Opc.Ua.Vision.Client` and
`Opc.Ua.Vision.Server`.
- Use `AddOpcUaMachineVision` and `AddOpcUaMachineVisionAMCM` for model
loading and factory registration. Each has node-collection,
encodeable-factory-builder and node-state-factory-builder overloads.
These short entry points delegate to the generated implementations;
existing namespace-derived generated names remain available for
compatibility.
- Support multiple independent named systems/assets under one
namespace-owning manager. Reuse injectable providers, file binding,
bounded memory stores, optional transactional `IFileSystemProvider`
persistence, state machines, telemetry and conformance infrastructure.
- Keep one `docs/Vision.md` guide, titled `Machine Vision (OPC 40100-1
and OPC 40100-2)`, and one `Opc.Ua.Mcp.Vision` package. Include
deterministic server/client samples.
- Expose 17 Machine Vision MCP tools, including capabilities/status,
bounded relationships and observation, complete small-file
uploads/downloads, management, explicit control, conditions, and chunked
transfers. Catalogue counts: 39 Vision-specific, 43 with connection
tools, 81 Vision+Robotics, 155 Full. Every Machine Vision wire parameter
has a schema description.

**Compatibility and limits:** `AddVision()` remains Perception-only
unless released parts are selected. Standalone Part 2 uses
`AddVision().AddMachineVision()`; Machinery composition uses
`AddVisionToMachinery()`. Existing tool defaults and command signatures
are preserved. Observation owns and cleans up its subscription: 5
seconds/50 notifications by default, capped at 60 seconds/100
notifications. Complete-file tools are capped at 1 MiB; larger files use
chunked transfers. Domain Error values remain distinct from UA service
failures.

**Versioning:** the seven Vision/MachineVision/MCP packages and their
Debug variants follow the repository root version instead of being
forced to preview. Vision.OpenUsd and its descendants remain
preview-only. Development builds still inherit the root prerelease
version. No release version, tag, release branch, publication policy, or
feed promotion is changed.

**Review actions:** all fourteen review threads have action/evidence
replies and are resolved. Changes include the unified guide, Perception
namespace, separate Part 2 packages and final naming, multi-system
ownership explanation, interrupted-write cleanup, observer failure
isolation, complete MCP parameter descriptions, and explicit package
inventory. The preview-layout migration topic and the requested
ReleaseProcess caveat were removed. The test Publish collector now
retries recognized transient failures within one cancellation budget and
propagates other exceptions. Commit 69b1f5db9 applies the requested
short loader names and renames standalone hosting to `AddMachineVision`,
with callers, tests and documentation updated. The intervening master
merge is preserved.

### Verification

**Latest API naming changes, verified locally:**

| Scope | Result |
| --- | --- |
| Full Vision suite, net10 | 572 passed, 1 existing skip, 0 failed |
| Full Vision suite, net48 | 515 passed, 0 skipped, 0 failed |
| Final loader/hosting cases after cross-TFM null-guard adjustment | 9
passed, 0 failed, 0 skipped |
| Machine Vision MCP and profile cases | 198 passed, 0 failed, 0 skipped
|
| Main and MCP AOT test-project compilation | Both built with 0
warnings/errors |
| MCP AOT companion, managed execution | 5 passed, 0 failed, 0 skipped;
not claimed as native execution |

`ModelLoaderNamesRegisterTypedFactories`,
`ModelLoaderNamesRejectNullArguments`, and
`GeneratedModelsLoadTogetherAndCreateTypedInstances` cover all six
loader overloads, fluent return identities, typed instances, namespace
coexistence and null guards. Existing standalone Part 2 tests verify
idempotent registration, one-owner enforcement and null handling through
`AddMachineVision`. No source-generator implementation, CLR namespace,
wire identity or model payload was changed for these naming updates.

The current 69b1f5db9 revision is running
[CI](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826937),
[package
validation](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165823359),
[MCP
usability](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826874),
[CodeQL](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826906),
and [Images
CI](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826884).
These runs are not claimed as completed results.

**Earlier completed verification:** all seven default-TFM package
archives were inspected after the package rename: exact IDs, 27 clean
DLLs, correct dependencies/readmes/licenses, and unchanged pinned model
hashes. [CI on
c75ec4fa6](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38142519760)
passed, including the formerly failing Linux batch and required summary.
Its unchanged coverage gate passed at **88.56% lines, 78.96% branches,
84.28% patch** against the 75% patch floor. The later
bounded-Publish-helper regressions reproduced 13 failures before the fix
and passed afterward; the full mainline triggering fixture passed 67
tests on each of net10/net48. These are historical results, not current
full-matrix results.

<details>
<summary>Executed API naming validation commands and outcomes</summary>

Run from the worktree root using the established artifact trees.
Dependency builds were performed before the no-build full net10 run; the
focused loader/hosting selection was rebuilt and rerun after the final
null-guard adjustment.

```powershell
$files = 'C:\Users\mschier\.copilot\session-state\deb2ba10-b0e9-4a44-8783-8331bc850814\files'
$env:DOTNET_CLI_USE_MSBUILD_SERVER = '0'
$env:TEMP = 'C:\Temp\mv-deb2\followup'
$env:TMP = $env:TEMP

# Passed: 572; Failed: 0; Skipped: 1.
dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Release -f net10.0 --no-build --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-net10" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup'

# Passed: 9; Failed: 0; Skipped: 0, on the final source.
dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Release -f net10.0 --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --filter 'FullyQualifiedName~MachineVisionModelTests|FullyQualifiedName~StandalonePartTwoRegistrationUsesOneOwnerAndOneModelProvider|FullyQualifiedName~StandalonePartTwoRequiresRegistrationAndRejectsCompetingOwnership' --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-final-model" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup'

# Passed: 198; Failed: 0; Skipped: 0.
dotnet test tests\Opc.Ua.Tools.Tests\Opc.Ua.Tools.Tests.csproj -c Release -f net10.0 --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --filter 'FullyQualifiedName~VisionMachineToolsTests|FullyQualifiedName~McpHostBuilderTests|FullyQualifiedName~McpToolProfileCompositionTests' --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-final-mcp" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup'

$env:TEMP = 'C:\Temp\mv-deb2\followup48'
$env:TMP = $env:TEMP

# Passed: 515; Failed: 0; Skipped: 0.
dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Debug -f net48 --no-restore --artifacts-path "$files\solution-net48-artifacts" -p:CustomTestTarget=net48 -p:UseSharedCompilation=false -m:1 -nr:false --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-net48-final" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup48'
```

Opt-in long-running/stress/durable tiers have not been rerun locally;
the unqualified **all tests** checklist item remains open. Original
implementation matrix counts are not presented as current results.

</details>

## Related Issues

- Fixes #4732.

The requester reviewed and approved the implementation plan and
subsequent packaging/API decisions in the development session. The
tracking issue records the design; maintainer architectural
discussion/sign-off there is still pending. This PR remains a draft.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
This commit is contained in:
Marc Schier
2026-10-12 09:01:27 +02:00
committed by GitHub
parent 0716603543
commit 1bb4bdca1d
250 changed files with 54382 additions and 1645 deletions
@@ -36,8 +36,8 @@ using Opc.Ua.Client;
using Opc.Ua.Mcp.Tools;
using Opc.Ua.Robotics.Client.Intent;
using Opc.Ua.RobotIntent;
using Opc.Ua.Vision;
using Opc.Ua.Vision.Client;
using Opc.Ua.Vision.Perception;
namespace Opc.Ua.Mcp
{
@@ -183,12 +183,9 @@ namespace Opc.Ua.Mcp
#pragma warning restore RCS1214
}
VisionDetectionSummary? summary = result.DetectionSummary;
if (summary is null)
{
VisionDetectionSummary? summary = result.DetectionSummary ??
throw new InvalidOperationException(string.Create(CultureInfo.InvariantCulture,
$"The Vision result '{result.ResultId}' carries no detection summary."));
}
if (summary.TotalDetections <= summary.Items.Count)
{
+26 -1
View File
@@ -36,7 +36,11 @@ unambiguous BrowseName/DisplayName:
}
```
## Tools (22)
This package follows the repository root version, without a forced-preview override.
Development builds still use the root prerelease version. The optional
`Opc.Ua.Vision.OpenUsd` renderer remains preview-only.
## Tools (39)
- **Discovery (4)** — `vision_list_sensors`, `vision_list_pipelines`,
`vision_list_frames`, `vision_list_calibrations`.
@@ -53,6 +57,27 @@ unambiguous BrowseName/DisplayName:
`vision_submit_image_reference`.
- **Geometry (3)** — `vision_read_frame`, `vision_compose_pose`,
`vision_compose_transform`.
- **Released Machine Vision (17)** — `vision_machine_list`,
`vision_machine_read_state`, `vision_machine_command`, `vision_machine_read_asset`,
`vision_machine_recipes`, `vision_machine_configurations`, `vision_machine_results`,
`vision_machine_report_safety`, `vision_machine_file`, `vision_machine_recipe_object`,
`vision_machine_condition`, `vision_machine_capabilities`,
`vision_machine_relationships`, `vision_machine_observe`,
`vision_machine_upload_recipe`, `vision_machine_upload_configuration`,
`vision_machine_download_result`. See the
[Machine Vision guide](https://github.com/OPCFoundation/UA-.NETStandard/blob/main/docs/Vision.md#mcp)
for OPC 40100-1/-2 behavior and payload bounds.
Capability discovery reports optional interfaces on the actual selected system.
Status detail is opt-in (`includeDetails: true`). One-shot observation defaults to
5 seconds/50 notifications, caps requests at 60 seconds/100 notifications, and
always disposes its own subscription. Conditions retain the exact identifiers
needed for explicit acknowledgement and confirmation.
The complete-file tools accept at most 1 MiB of decoded content and never prepare,
activate, or start acquisition implicitly. Larger files use the existing chunked
tool; its `ReadCompletion` and `WaitForCompletion` operations inspect a returned
commit state machine. A completion wait is capped at 60 seconds.
Server refusals are returned honestly with the exact `StatusCode` and message;
the MCP layer does not retry and never acquires command authority as a side
@@ -8,7 +8,7 @@
<AssemblyName>$(AssemblyPrefix).Mcp.Vision</AssemblyName>
<PackageId>$(PackagePrefix).Opc.Ua.Mcp.Vision</PackageId>
<RootNamespace>Opc.Ua.Mcp</RootNamespace>
<Description>OPC UA MCP tools for Vision: let a language model see through an OPC UA Vision server and act on what it sees. Discovery, image-content capture, inference, off-server perception feedback, and frame-graph composition. Embed in any MCP server host.</Description>
<Description>OPC UA MCP tools for OPC 40100-1/-2 Machine Vision control, management, file transfer, assets and health, and Perception discovery, image capture, inference, feedback and frame graphs. Embed one bounded catalog for all Vision models in any MCP server host.</Description>
<Company>OPC Foundation</Company>
<Copyright>Copyright © 2004-2026 OPC Foundation, Inc</Copyright>
<PackageTags>OPCFoundation OPC UA MCP ModelContextProtocol Vision AI LLM VLM</PackageTags>
@@ -30,6 +30,7 @@
<ItemGroup>
<ProjectReference Include="..\Opc.Ua.Mcp.Core\Opc.Ua.Mcp.Core.csproj" />
<ProjectReference Include="..\..\src\Opc.Ua.Vision.Client\Opc.Ua.Vision.Client.csproj" />
<ProjectReference Include="..\..\src\Opc.Ua.MachineVision.Client\Opc.Ua.MachineVision.Client.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="NugetREADME.md" Pack="true" PackagePath="\" />
@@ -29,7 +29,7 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Server;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Opc.Ua.Mcp.Tools;
namespace Opc.Ua.Mcp
@@ -46,6 +46,7 @@ namespace Opc.Ua.Mcp
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton<VisionClientAccessor>();
return services;
}
@@ -78,7 +79,8 @@ namespace Opc.Ua.Mcp
.WithTools<VisionSeeingTools>()
.WithTools<VisionInferenceTools>()
.WithTools<VisionFeedbackTools>()
.WithTools<VisionGeometryTools>();
.WithTools<VisionGeometryTools>()
.WithTools<VisionMachineTools>();
if (toolProfile == McpToolProfile.Vision)
{
@@ -116,7 +118,7 @@ namespace Opc.Ua.Mcp
/// wants the Vision tools alongside the tools of another bounded
/// profile - <see cref="McpToolProfile.Robotics"/>, for a vision-guided
/// pick-and-place agent, for example. It never registers
/// <see cref="Tools.ConnectionTools"/> directly; the
/// <see cref="ConnectionTools"/> directly; the
/// <c>McpToolProfileSet</c> overload of <c>WithOpcUaCoreTools</c> owns
/// that registration and deduplicates it across every package that
/// contributes to the same MCP server.
@@ -147,7 +149,8 @@ namespace Opc.Ua.Mcp
.WithTools<VisionSeeingTools>()
.WithTools<VisionInferenceTools>()
.WithTools<VisionFeedbackTools>()
.WithTools<VisionGeometryTools>();
.WithTools<VisionGeometryTools>()
.WithTools<VisionMachineTools>();
}
}
}
@@ -32,8 +32,8 @@ using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using ModelContextProtocol.Server;
using Opc.Ua.Vision;
using Opc.Ua.Vision.Client;
using Opc.Ua.Vision.Perception;
namespace Opc.Ua.Mcp.Tools
{
@@ -164,10 +164,10 @@ namespace Opc.Ua.Mcp.Tools
}
ArrayOf<VisionDetectionDataType> detections = hasDetections
? VisionJson.BuildDetections(correctedDetectionsJson!)
: ArrayOf<VisionDetectionDataType>.Empty;
: [];
ArrayOf<VisionCharacteristicDataType> characteristics = hasCharacteristics
? VisionJson.BuildCharacteristics(correctedCharacteristicsJson!)
: ArrayOf<VisionCharacteristicDataType>.Empty;
: [];
LocalizedText localizedReason = string.IsNullOrEmpty(reason)
? LocalizedText.Null
: new LocalizedText(reason);
@@ -32,8 +32,8 @@ using System.Threading;
using System.Threading.Tasks;
using ModelContextProtocol.Server;
using Opc.Ua.Mcp.Serialization;
using Opc.Ua.Vision;
using Opc.Ua.Vision.Client;
using Opc.Ua.Vision.Perception;
namespace Opc.Ua.Mcp.Tools
{
@@ -148,7 +148,7 @@ namespace Opc.Ua.Mcp.Tools
"Invalid detail value.");
}
if (request.MaxItems < 0 || request.MaxItems > 100)
if (request.MaxItems is < 0 or > 100)
{
throw new System.ArgumentOutOfRangeException(
nameof(request),
@@ -335,7 +335,7 @@ namespace Opc.Ua.Mcp.Tools
return new VisionInferenceDetectionSummary
{
TotalDetections = summary.TotalDetections,
Items = items.ToArray()
Items = [.. items]
};
}
@@ -365,7 +365,7 @@ namespace Opc.Ua.Mcp.Tools
PartId = summary.PartId,
RecipeId = summary.RecipeId,
TotalCharacteristics = summary.TotalCharacteristics,
Items = items.ToArray()
Items = [.. items]
};
}
@@ -442,7 +442,7 @@ namespace Opc.Ua.Mcp.Tools
/// <summary>
/// Overall inspection evaluation.
/// </summary>
public Vision.VisionResultEvaluationEnum Evaluation { get; init; }
public Vision.Perception.VisionResultEvaluationEnum Evaluation { get; init; }
/// <summary>
/// Inspected part identifier, when reported.
@@ -478,7 +478,7 @@ namespace Opc.Ua.Mcp.Tools
/// <summary>
/// Tolerance status.
/// </summary>
public Vision.VisionToleranceStatusEnum Status { get; init; }
public Vision.Perception.VisionToleranceStatusEnum Status { get; init; }
/// <summary>
/// Deviation from the nominal value.
+6 -7
View File
@@ -31,8 +31,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;
using Opc.Ua.Mcp.Serialization;
using Opc.Ua.Vision;
using Opc.Ua.Vision.Perception;
namespace Opc.Ua.Mcp.Tools
{
@@ -51,7 +50,7 @@ namespace Opc.Ua.Mcp.Tools
"Detections JSON must be a non-empty array.", nameof(detectionsJson));
}
using JsonDocument document = JsonDocument.Parse(detectionsJson);
using var document = JsonDocument.Parse(detectionsJson);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
throw new ArgumentException(
@@ -80,7 +79,7 @@ namespace Opc.Ua.Mcp.Tools
"Characteristics JSON must be a non-empty array.", nameof(characteristicsJson));
}
using JsonDocument document = JsonDocument.Parse(characteristicsJson);
using var document = JsonDocument.Parse(characteristicsJson);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
throw new ArgumentException(
@@ -108,7 +107,7 @@ namespace Opc.Ua.Mcp.Tools
throw new ArgumentException("Pose JSON must be a non-empty object.", parameterName);
}
using JsonDocument document = JsonDocument.Parse(poseJson);
using var document = JsonDocument.Parse(poseJson);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
throw new ArgumentException("Pose JSON must be an object.", parameterName);
@@ -125,7 +124,7 @@ namespace Opc.Ua.Mcp.Tools
"Image reference JSON must be a non-empty object.", parameterName);
}
using JsonDocument document = JsonDocument.Parse(imageJson);
using var document = JsonDocument.Parse(imageJson);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
throw new ArgumentException(
@@ -227,7 +226,7 @@ namespace Opc.Ua.Mcp.Tools
ArrayOf<double> position = ReadDoubleArray(element, "position", 3, $"{parameterName}.position");
ArrayOf<double> orientation = ReadDoubleArray(
element, "orientation", 4, $"{parameterName}.orientation");
ArrayOf<double> covariance = ArrayOf<double>.Empty;
ArrayOf<double> covariance = [];
if (element.TryGetProperty("covariance", out JsonElement covarianceElement) &&
covarianceElement.ValueKind == JsonValueKind.Array)
{
@@ -0,0 +1,261 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using ModelContextProtocol.Server;
using Opc.Ua.Client.StateMachines;
using Opc.Ua.Mcp.Serialization;
using Opc.Ua.Vision.Client;
using Opc.Ua.Vision.MachineVision;
namespace Opc.Ua.Mcp.Tools
{
public sealed partial class VisionMachineTools
{
/// <summary>
/// Executes standard file operations over the released specialized transfer types.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException"></exception>
/// <exception cref="ArgumentException"></exception>
[McpServerTool(Name = "vision_machine_file")]
[Description("Chunked OPC 40100 file operation. GenerateRead/GenerateWrite/Commit use a RecipeTransfer, " +
"ConfigurationTransfer, or ResultTransfer node. " +
"Read/Write/Close/GetPosition/SetPosition/Open use a FileType node. " +
"kind is Recipe, Configuration, or Result; id is the internal document ID for Generate*. " +
"Generated handles are already open. Commit publishes a write; " +
"Close without Commit aborts a temporary upload. Chunks are capped at 1 MiB. " +
"ReadCompletion/WaitForCompletion use the returned completion-state-machine NodeId; " +
"WaitForCompletion waits at most 60 seconds and fails on Error, timeout, or unavailable state.")]
public static async Task<JsonElement> FileAsync(
VisionClientAccessor accessor,
[Description("Transfer, file, or completion-state-machine NodeId selected as described by operation.")]
string nodeId,
[Description("GenerateRead, GenerateWrite, Commit, Open, Read, Write, Close, GetPosition, SetPosition, " +
"ReadCompletion, or WaitForCompletion; names are case-sensitive.")]
string operation,
[Description("Document kind for GenerateRead/GenerateWrite: Recipe, Configuration, or Result.")]
string kind = "Recipe",
[Description("Internal document identifier for GenerateRead/GenerateWrite; not a NodeId or filename.")]
string id = "",
[Description("Session-bound file handle returned by Open or GenerateRead/GenerateWrite.")]
uint handle = 0,
[Description("Requested Read chunk size in bytes, from 1 to 1048576; default 65536.")]
[Range(1, 1048576)] int length = 65536,
[Description("Base64 data for Write, with at most 1048576 decoded bytes; for example AQID encodes 1,2,3.")]
string? dataBase64 = null,
[Description("Absolute byte offset for SetPosition; zero denotes the start of the file.")]
ulong position = 0,
[Description("Open bitmask: Read=1, Write=2, EraseExisting=4, Append=8; combine compatible flags.")]
byte mode = 1,
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
if (length is <= 0 or > 1048576)
{
throw new ArgumentOutOfRangeException(nameof(length));
}
VisionClient client = accessor.CreateClient(sessionName);
NodeId target = OpcUaJsonHelper.ParseNodeId(nodeId);
if (operation is "ReadCompletion" or "WaitForCompletion")
{
var completion = new FileTransferStateMachineTypeClient(client.Session, target, client.Telemetry);
FiniteStateSnapshot snapshot = operation == "ReadCompletion"
? await completion.GetCurrentFiniteStateAsync(ct).ConfigureAwait(false)
: await completion.WaitForCompletionAsync(
TimeSpan.FromSeconds(60), accessor.TimeProvider, ct).ConfigureAwait(false);
return Element(State(snapshot));
}
if (operation is "GenerateRead" or "GenerateWrite" or "Commit")
{
var transfer = new TemporaryFileTransferTypeClient(client.Session, target, client.Telemetry);
if (operation == "Commit")
{
return Response(client, ("CompletionStateMachine",
Variant.From(await transfer.CloseAndCommitAsync(handle, ct).ConfigureAwait(false))));
}
Variant options = kind switch
{
"Recipe" => Variant.FromStructure(new RecipeTransferOptions
{
InternalId = new RecipeIdInternalDataType { Id = id }
}),
"Configuration" => Variant.FromStructure(new ConfigurationTransferOptions
{
InternalId = new ConfigurationIdDataType { Id = id }
}),
"Result" => Variant.FromStructure(new ResultTransferOptions
{
Id = new ResultIdDataType { Id = id }
}),
_ => throw new ArgumentException("Unknown document kind.", nameof(kind))
};
if (operation == "GenerateRead")
{
(NodeId fileNodeId, uint fileHandle, NodeId completionStateMachine) =
await transfer.GenerateFileForReadAsync(options, ct).ConfigureAwait(false);
return Response(client, ("FileNodeId", Variant.From(fileNodeId)),
("Handle", Variant.From(fileHandle)),
("CompletionStateMachine", Variant.From(completionStateMachine)));
}
(NodeId fileNodeId, uint fileHandle) created =
await transfer.GenerateFileForWriteAsync(options, ct).ConfigureAwait(false);
return Response(client, ("FileNodeId", Variant.From(created.fileNodeId)),
("Handle", Variant.From(created.fileHandle)));
}
var file = new FileTypeClient(client.Session, target, client.Telemetry);
switch (operation)
{
case "Open":
return Response(client, ("Handle",
Variant.From(await file.OpenAsync(mode, ct).ConfigureAwait(false))));
case "Read":
return Response(client, ("Data",
Variant.From(await file.ReadAsync(handle, length, ct).ConfigureAwait(false))));
case "Write":
if (dataBase64 == null || dataBase64.Length > 1398104)
{
throw new ArgumentException(
"Supply at most 1 MiB of base64-encoded data.", nameof(dataBase64));
}
byte[] bytes = Convert.FromBase64String(dataBase64);
if (bytes.Length > 1048576)
{
throw new ArgumentOutOfRangeException(nameof(dataBase64));
}
await file.WriteAsync(handle, ByteString.From(bytes), ct).ConfigureAwait(false);
break;
case "Close":
await file.CloseAsync(handle, ct).ConfigureAwait(false);
break;
case "GetPosition":
return Response(client, ("Position",
Variant.From(await file.GetPositionAsync(handle, ct).ConfigureAwait(false))));
case "SetPosition":
await file.SetPositionAsync(handle, position, ct).ConfigureAwait(false);
break;
default:
throw new ArgumentException("Unknown file operation.", nameof(operation));
}
return Response(client, ("Status", Variant.From(StatusCodes.Good)));
}
/// <summary>
/// Invokes methods on an address-space recipe object.
/// </summary>
/// <exception cref="ArgumentException"></exception>
[McpServerTool(Name = "vision_machine_recipe_object")]
[Description("Invokes Prepare, Unprepare, LinkProduct, or UnlinkProduct " +
"on a published OPC 40100 RecipeType object. " +
"Use the RecipeNodeId returned by Add; this is not the recipe's internal ID string.")]
public static async Task<JsonElement> RecipeObjectAsync(
VisionClientAccessor accessor,
[Description("Recipe object NodeId returned as RecipeNodeId by Add; not the recipe's internal ID.")]
string recipeNodeId,
[Description("Prepare, Unprepare, LinkProduct, or UnlinkProduct; operation names are case-sensitive.")]
string operation,
[Description("Product identifier for LinkProduct or UnlinkProduct; ignored by preparation operations.")]
string productId = "",
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
VisionClient client = accessor.CreateClient(sessionName);
var recipe = new RecipeTypeClient(
client.Session, OpcUaJsonHelper.ParseNodeId(recipeNodeId), client.Telemetry);
if (operation == "Prepare")
{
(bool isCompleted, int error) prepared = await recipe.PrepareAsync(ct).ConfigureAwait(false);
return Response(client, ("IsCompleted", Variant.From(prepared.isCompleted)),
("Error", Variant.From(prepared.error)));
}
int error = operation switch
{
"Unprepare" => await recipe.UnprepareAsync(ct).ConfigureAwait(false),
"LinkProduct" => await recipe.LinkProductAsync(new ProductIdDataType { Id = productId }, ct)
.ConfigureAwait(false),
"UnlinkProduct" => await recipe.UnlinkProductAsync(new ProductIdDataType { Id = productId }, ct)
.ConfigureAwait(false),
_ => throw new ArgumentException("Unknown recipe-object operation.", nameof(operation))
};
return Response(client, ("Error", Variant.From(error)));
}
/// <summary>
/// Acknowledges or confirms a specific condition event.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException"></exception>
/// <exception cref="ArgumentException"></exception>
[McpServerTool(Name = "vision_machine_condition")]
[Description("Acknowledges or confirms a vision condition " +
"using the exact EventId received from its notification. " +
"operation is Acknowledge or Confirm. Confirmation does not imply that the physical error is resolved.")]
public static async Task<JsonElement> ConditionAsync(
VisionClientAccessor accessor,
[Description("Condition instance NodeId returned as conditionNodeId by vision_machine_observe.")]
string conditionNodeId,
[Description("Exact eventIdBase64 from the condition notification being acknowledged or confirmed.")]
string eventIdBase64,
[Description("Acknowledge or Confirm (case-sensitive); neither operation clears the physical cause.")]
string operation,
[Description("Operator comment attached to this acknowledgement or confirmation; default empty text.")]
string comment = "",
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
if (eventIdBase64.Length > 4096)
{
throw new ArgumentOutOfRangeException(nameof(eventIdBase64));
}
VisionClient client = accessor.CreateClient(sessionName);
var condition = new AcknowledgeableConditionTypeClient(
client.Session, OpcUaJsonHelper.ParseNodeId(conditionNodeId), client.Telemetry);
var eventId = ByteString.From(Convert.FromBase64String(eventIdBase64));
if (operation == "Acknowledge")
{
await condition.AcknowledgeAsync(eventId, new LocalizedText(comment), ct).ConfigureAwait(false);
}
else if (operation == "Confirm")
{
await condition.ConfirmAsync(eventId, new LocalizedText(comment), ct).ConfigureAwait(false);
}
else
{
throw new ArgumentException("Unknown condition operation.", nameof(operation));
}
return Response(client, ("Status", Variant.From(StatusCodes.Good)));
}
}
}
@@ -0,0 +1,431 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using ModelContextProtocol.Server;
using Opc.Ua.Mcp.Serialization;
using Opc.Ua.Vision.Client;
using Opc.Ua.Vision.MachineVision;
namespace Opc.Ua.Mcp.Tools
{
public sealed partial class VisionMachineTools
{
/// <summary>
/// Discovers namespace support and actual optional interfaces without executing methods.
/// </summary>
[McpServerTool(Name = "vision_machine_capabilities")]
[Description("Reports independent Part 1/Part 2 namespace support. With systemNodeId, also resolves actual " +
"control, management, transfer, safety and step interfaces and their instance method executability. " +
"An absent interface has available=false; a loaded namespace alone does not imply interface support.")]
public static async Task<JsonElement> CapabilitiesAsync(
VisionClientAccessor accessor,
[Description("Optional Part 1 system NodeId for interface inspection; omit for namespace support only.")]
string? systemNodeId = null,
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
NodeId node = string.IsNullOrWhiteSpace(systemNodeId)
? NodeId.Null : OpcUaJsonHelper.ParseNodeId(systemNodeId);
VisionClient client = accessor.CreateClient(sessionName);
MachineVisionCapabilities capabilities = await accessor.CreateMachineVisionClient(sessionName)
.ReadCapabilitiesAsync(node, ct).ConfigureAwait(false);
var interfaces = new JsonArray();
foreach (MachineVisionInterfaceCapability entry in capabilities.Interfaces)
{
var methods = new JsonArray();
foreach (MachineVisionMethodCapability method in entry.Methods)
{
AddNode(methods, new JsonObject
{
["nodeId"] = method.NodeId.ToString(),
["browseName"] = method.BrowseName.ToString(),
["executable"] = Reading(client, method.Executable),
["userExecutable"] = Reading(client, method.UserExecutable)
});
}
AddNode(interfaces, new JsonObject
{
["path"] = entry.Path,
["nodeId"] = entry.NodeId.IsNull ? null : entry.NodeId.ToString(),
["available"] = !entry.NodeId.IsNull,
["methods"] = methods
});
}
return Element(new JsonObject
{
["supportsControl"] = capabilities.SupportsControl,
["supportsAssets"] = capabilities.SupportsAssets,
["interfaces"] = interfaces
});
}
/// <summary>
/// Reads one bounded page of a selected relationship.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException"></exception>
/// <exception cref="ArgumentException"></exception>
[McpServerTool(Name = "vision_machine_relationships")]
[Description("Reads one-hop SameEntity, OpticalPath, DataTransmission, or Interface relationships. " +
"direction is Forward, Inverse, or Both. Preserves reference direction, namespaces, and vendor types. " +
"Use nextIndex when isComplete=false; pages are live views, not a snapshot across topology edits.")]
public static async Task<JsonElement> RelationshipsAsync(
VisionClientAccessor accessor,
[Description("Source system, asset or component NodeId whose one-hop relationships should be read.")]
string nodeId,
[Description("SameEntity, OpticalPath, DataTransmission, or Interface; default SameEntity.")]
string relationship = "SameEntity",
[Description("Reference direction: Forward, Inverse, or Both; default Both.")]
string direction = "Both",
[Description("Maximum references returned in this page, from 1 to 100; default 50.")]
[Range(1, 100)] uint maximumResults = 50,
[Description("Zero-based reference offset, from 0 to 10000; use nextIndex to request another page.")]
[Range(0, 10000)] uint startIndex = 0,
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
ValidatePage(maximumResults);
if (startIndex > 10000)
{
throw new ArgumentOutOfRangeException(nameof(startIndex));
}
MachineVisionRelationship kind = relationship switch
{
"SameEntity" => MachineVisionRelationship.SameEntity,
"OpticalPath" => MachineVisionRelationship.OpticalPath,
"DataTransmission" => MachineVisionRelationship.DataTransmission,
"Interface" => MachineVisionRelationship.Interface,
_ => throw new ArgumentException("Unknown relationship.", nameof(relationship))
};
BrowseDirection browseDirection = direction switch
{
"Forward" => BrowseDirection.Forward,
"Inverse" => BrowseDirection.Inverse,
"Both" => BrowseDirection.Both,
_ => throw new ArgumentException("Unknown browse direction.", nameof(direction))
};
MachineVisionRelationshipPage page = await accessor.CreateMachineVisionClient(sessionName)
.ReadRelationshipsAsync(OpcUaJsonHelper.ParseNodeId(nodeId), kind, browseDirection,
maximumResults, startIndex, ct).ConfigureAwait(false);
var references = new JsonArray();
foreach (ReferenceDescription reference in page.References)
{
AddNode(references, new JsonObject
{
["nodeId"] = reference.NodeId.ToString(),
["browseName"] = reference.BrowseName.ToString(),
["displayName"] = reference.DisplayName.Text,
["referenceTypeId"] = reference.ReferenceTypeId.ToString(),
["isForward"] = reference.IsForward,
["typeDefinition"] = reference.TypeDefinition.ToString(),
["nodeClass"] = reference.NodeClass.ToString()
});
}
return Element(new JsonObject
{
["references"] = references,
["nextIndex"] = page.NextIndex,
["isComplete"] = page.IsComplete
});
}
/// <summary>
/// Collects a bounded event window using a subscription owned only by this call.
/// </summary>
/// <exception cref="ArgumentException"></exception>
[McpServerTool(Name = "vision_machine_observe")]
[Description("Observes Events or Conditions for 5 seconds/50 notifications by default, at most " +
"60 seconds/100 notifications. refreshConditions requests retained conditions in Conditions mode. " +
"Returns exact eventIdBase64/conditionNodeId selectors and namespace-qualified fields. " +
"refreshComplete=false is not a complete retained-condition snapshot. " +
"Always cleans up its subscription; " +
"never acknowledges conditions, starts acquisition, or leaves a monitoring handle.")]
public static async Task<JsonElement> ObserveAsync(
VisionClientAccessor accessor,
[Description("Event-notifier NodeId to monitor, such as i=2253 for the Server object.")]
string notifierNodeId,
[Description("Events or Conditions; Conditions selects condition fields needed for acknowledgement.")]
string mode = "Events",
[Description("Request a retained-condition refresh; only valid in Conditions mode, default false.")]
bool refreshConditions = false,
[Description("Observation window in seconds, from 1 to 60; default 5.")]
[Range(1, 60)] int durationSeconds = 5,
[Description("Maximum notifications to retain, from 1 to 100; default 50.")]
[Range(1, 100)] int maximumNotifications = 50,
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
var options = new MachineVisionObservationOptions
{
Mode = mode switch
{
"Events" => MachineVisionObservationMode.Events,
"Conditions" => MachineVisionObservationMode.Conditions,
_ => throw new ArgumentException("Unknown observation mode.", nameof(mode))
},
RefreshConditions = refreshConditions,
Window = TimeSpan.FromSeconds(durationSeconds),
MaximumNotifications = maximumNotifications
};
options.Validate();
VisionClient client = accessor.CreateClient(sessionName);
MachineVisionObservation observation = await accessor.CreateMachineVisionClient(sessionName)
.ObserveAsync(OpcUaJsonHelper.ParseNodeId(notifierNodeId), options, ct).ConfigureAwait(false);
var paths = new JsonArray();
foreach (ArrayOf<QualifiedName> path in observation.FieldPaths)
{
var names = new JsonArray();
foreach (QualifiedName name in path)
{
AddNode(names, JsonValue.Create(name.ToString()));
}
AddNode(paths, names);
}
var events = new JsonArray();
foreach (MachineVisionObservedEvent notification in observation.Events)
{
using var encoder = new JsonEncoder(client.Session.MessageContext);
encoder.WriteVariantArray("Fields", notification.Fields);
AddNode(events, new JsonObject
{
["eventType"] = notification.EventType.ToString(),
["eventIdBase64"] = Convert.ToBase64String(notification.EventId.Span),
["conditionNodeId"] = notification.ConditionId.IsNull ? null : notification.ConditionId.ToString(),
["data"] = JsonNode.Parse(encoder.CloseAndReturnText())
});
}
return Element(new JsonObject
{
["completionReason"] = observation.LimitReached ? "LimitReached" : "WindowElapsed",
["refreshComplete"] = observation.RefreshComplete,
["droppedNotifications"] = observation.DroppedNotifications,
["fieldPaths"] = paths,
["events"] = events
});
}
/// <summary>
/// Registers and transfers a bounded recipe, waiting for commit but not preparing it.
/// </summary>
[McpServerTool(Name = "vision_machine_upload_recipe")]
[Description("Registers recipe metadata and uploads at most 1 MiB of decoded base64 content when required. " +
"Waits for server commit completion. Does not prepare the recipe, change mode, or start a job. " +
"Use vision_machine_file for larger uploads. identifierJson preserves complete UA identifier data.")]
public static async Task<JsonElement> UploadRecipeAsync(
VisionClientAccessor accessor,
[Description("Part 1 system NodeId returned by vision_machine_list, such as ns=2;s=VisionSystem.")]
string systemNodeId,
[Description("Complete base64 recipe content, at most 1048576 decoded bytes; may be empty.")]
string dataBase64,
[Description("External recipe ID to register, unless supplied in identifierJson; not a NodeId.")]
string externalId = "",
[Description("Optional product identifier to associate with the recipe; default empty identifier.")]
string productId = "",
[Description("External recipe version used with externalId; empty leaves the version unspecified.")]
string version = "",
[Description("Optional UA JSON recipe identifier with Id, Version, Hash, HashAlgorithm and Description.")]
string? identifierJson = null,
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
ValidateInputJson(identifierJson);
using var content = new MemoryStream(DecodeContent(dataBase64), writable: false);
VisionClient client = accessor.CreateClient(sessionName);
RecipeIdExternalDataType identifier =
Identifier<RecipeIdExternalDataType>(client, externalId, version, identifierJson);
RecipeIdInternalDataType id = await System(client, systemNodeId).UploadRecipeAsync(
identifier, new ProductIdDataType { Id = productId }, content, ct).ConfigureAwait(false);
return Response(client, ("InternalId", Variant.FromStructure(id)), ("Error", Variant.From(0)));
}
/// <summary>
/// Registers and transfers a bounded configuration without activating it.
/// </summary>
[McpServerTool(Name = "vision_machine_upload_configuration")]
[Description("Registers and uploads a configuration with at most 1 MiB of decoded base64 content. " +
"Waits for asynchronous server commit completion; activation remains a separate explicit command. " +
"Use vision_machine_file for larger chunked content. identifierJson preserves version/hash metadata.")]
public static async Task<JsonElement> UploadConfigurationAsync(
VisionClientAccessor accessor,
[Description("Part 1 system NodeId returned by vision_machine_list, such as ns=2;s=VisionSystem.")]
string systemNodeId,
[Description("Complete base64 configuration content, at most 1048576 decoded bytes; may be empty.")]
string dataBase64,
[Description("External configuration ID to register, unless supplied in identifierJson; not a NodeId.")]
string externalId = "",
[Description("Configuration version used with externalId; empty leaves the version unspecified.")]
string version = "",
[Description("UA JSON configuration identifier with Id, Version, Hash, HashAlgorithm and Description.")]
string? identifierJson = null,
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
ValidateInputJson(identifierJson);
using var content = new MemoryStream(DecodeContent(dataBase64), writable: false);
VisionClient client = accessor.CreateClient(sessionName);
ConfigurationIdDataType identifier =
Identifier<ConfigurationIdDataType>(client, externalId, version, identifierJson);
ConfigurationIdDataType id = await System(client, systemNodeId).UploadConfigurationAsync(
identifier, content, ct).ConfigureAwait(false);
return Response(client, ("InternalId", Variant.FromStructure(id)), ("Error", Variant.From(0)));
}
/// <summary>
/// Downloads an entire bounded result and releases its file and metadata handles.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException"></exception>
[McpServerTool(Name = "vision_machine_download_result")]
[Description("Downloads complete result file bytes and releases file/metadata handles " +
"on success or failure. " +
"maximumBytes is 1-1048576; exceeding it fails rather than returning truncated content. " +
"Use the chunked file tool for larger results. Returns Data as an OPC UA ByteString.")]
public static async Task<JsonElement> DownloadResultAsync(
VisionClientAccessor accessor,
[Description("Part 1 system NodeId returned by vision_machine_list, such as ns=2;s=VisionSystem.")]
string systemNodeId,
[Description("Result identifier returned by List/Get in vision_machine_results; not the result's NodeId.")]
string resultId,
[Description("Maximum complete result bytes (1-1048576); larger results fail without truncation.")]
[Range(1, 1048576)] int maximumBytes = 1048576,
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
if (maximumBytes is < 1 or > 1048576)
{
throw new ArgumentOutOfRangeException(nameof(maximumBytes));
}
using var content = new BoundedResultStream(maximumBytes);
VisionClient client = accessor.CreateClient(sessionName);
await System(client, systemNodeId).DownloadResultAsync(
new ResultIdDataType { Id = resultId }, content, ct).ConfigureAwait(false);
return Response(client, ("Data", Variant.From(ByteString.From(content.ToArray()))),
("Length", Variant.From(content.Length)));
}
private static JsonObject StatusDetails(VisionClient client, MachineVisionStatusSnapshot snapshot)
{
var values = new JsonArray();
foreach (MachineVisionStatusValue value in snapshot.Values)
{
AddNode(values, new JsonObject
{
["path"] = value.Path,
["nodeId"] = value.NodeId.IsNull ? null : value.NodeId.ToString(),
["reading"] = Reading(client, value.Value)
});
}
var steps = new JsonArray();
foreach (MachineVisionStepSnapshot step in snapshot.Steps)
{
AddNode(steps, new JsonObject
{
["path"] = step.Path,
["nodeId"] = step.NodeId.ToString(),
["state"] = State(step.State)
});
}
return new JsonObject { ["values"] = values, ["steps"] = steps };
}
private static JsonNode? Reading(VisionClient client, in DataValue value)
{
using var encoder = new JsonEncoder(client.Session.MessageContext);
encoder.WriteDataValue("DataValue", value);
return JsonNode.Parse(encoder.CloseAndReturnText());
}
private static byte[] DecodeContent(string dataBase64)
{
ArgumentNullException.ThrowIfNull(dataBase64);
if (dataBase64.Length > 1398104)
{
throw new ArgumentOutOfRangeException(nameof(dataBase64), "Use file chunks for content over 1 MiB.");
}
byte[] bytes = Convert.FromBase64String(dataBase64);
if (bytes.Length > 1048576)
{
throw new ArgumentOutOfRangeException(nameof(dataBase64), "Use file chunks for content over 1 MiB.");
}
return bytes;
}
/// <summary>
/// Enforces the content limit before a download can expand its destination buffer.
/// </summary>
private sealed class BoundedResultStream(int maximumBytes) : MemoryStream
{
public override void Write(byte[] buffer, int offset, int count)
{
CheckLength(count);
base.Write(buffer, offset, count);
}
public override void Write(ReadOnlySpan<byte> buffer)
{
CheckLength(buffer.Length);
base.Write(buffer);
}
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
CheckLength(count);
return base.WriteAsync(buffer, offset, count, cancellationToken);
}
public override ValueTask WriteAsync(
ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
CheckLength(buffer.Length);
return base.WriteAsync(buffer, cancellationToken);
}
private void CheckLength(int count)
{
if (count > maximumBytes - Position)
{
throw new ServiceResultException(StatusCodes.BadEncodingLimitsExceeded,
"The result exceeds maximumBytes; use the chunked file tool.");
}
}
}
}
}
@@ -0,0 +1,734 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using ModelContextProtocol.Server;
using Opc.Ua.Client.StateMachines;
using Opc.Ua.Mcp.Serialization;
using Opc.Ua.Vision.Client;
using Opc.Ua.Vision.MachineVision;
namespace Opc.Ua.Mcp.Tools
{
/// <summary>
/// Released OPC 40100 control, management, asset, and file tools in the Vision catalog.
/// Perception tools have independent names and contracts.
/// </summary>
[McpServerToolType]
public sealed partial class VisionMachineTools
{
/// <summary>
/// Discovers released control systems or system assets.
/// </summary>
[McpServerTool(Name = "vision_machine_list")]
[Description("Discovers released OPC 40100-1 systems, or OPC 40100-2 assets when assets=true. " +
"Works without the Perception namespace. Does not change device state.")]
public static async Task<JsonElement> ListAsync(
VisionClientAccessor accessor,
[Description("True discovers Part 2 assets; false discovers Part 1 control systems.")]
bool assets = false,
[Description("Optional browse root NodeId, such as i=85; omit to use the client's discovery root.")]
string? rootNodeId = null,
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
MachineVisionClient client = accessor.CreateClient(sessionName).MachineVision();
NodeId root = string.IsNullOrWhiteSpace(rootNodeId)
? NodeId.Null : OpcUaJsonHelper.ParseNodeId(rootNodeId);
var entries = new JsonArray();
await foreach (VisionNodeEntry entry in (assets
? client.EnumerateAssetsAsync(root, cancellationToken: ct)
: client.EnumerateSystemsAsync(root, cancellationToken: ct)).ConfigureAwait(false))
{
AddNode(entries, new JsonObject
{
["nodeId"] = entry.NodeId.ToString(),
["browseName"] = entry.BrowseName.ToString(),
["displayName"] = entry.DisplayName.Text,
["typeDefinition"] = entry.TypeDefinitionId.ToString()
});
}
return Element(entries);
}
/// <summary>
/// Reads the two released control state machines.
/// </summary>
public static Task<JsonElement> ReadStateAsync(
VisionClientAccessor accessor,
string systemNodeId,
string? sessionName = null,
CancellationToken ct = default)
{
return ReadStateWithDetailsAsync(accessor, systemNodeId, false, sessionName, ct);
}
/// <summary>
/// Reads the existing state response with optional diagnostics, safety, configuration, and step details.
/// </summary>
[McpServerTool(Name = "vision_machine_read_state")]
[Description("Reads control and automatic-mode states without changing device state. " +
"includeDetails adds optional diagnostics, SystemState, active configuration, safety, and step states " +
"with their actual quality/status. The default response is unchanged.")]
public static async Task<JsonElement> ReadStateWithDetailsAsync(
VisionClientAccessor accessor,
[Description("Part 1 system NodeId returned by vision_machine_list, such as ns=2;s=VisionSystem.")]
string systemNodeId,
[Description("Include optional diagnostics, safety, active configuration and step states; default false.")]
bool includeDetails = false,
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
VisionClient client = accessor.CreateClient(sessionName);
MachineVisionSystemClient system = System(client, systemNodeId);
VisionStateMachineTypeClient control = await system.ControlAsync(ct).ConfigureAwait(false);
VisionAutomaticModeStateMachineTypeClient automatic = await system.AutomaticAsync(ct)
.ConfigureAwait(false);
FiniteStateSnapshot top = await control.GetCurrentFiniteStateAsync(ct).ConfigureAwait(false);
FiniteStateSnapshot mode = await automatic.GetCurrentFiniteStateAsync(ct).ConfigureAwait(false);
var response = new JsonObject
{
["control"] = State(top),
["automatic"] = State(mode)
};
if (includeDetails)
{
MachineVisionStatusSnapshot details = await accessor.CreateMachineVisionClient(sessionName)
.ReadStatusAsync(OpcUaJsonHelper.ParseNodeId(systemNodeId), ct).ConfigureAwait(false);
response["details"] = StatusDetails(client, details);
}
return Element(response);
}
/// <summary>
/// Executes one explicit state-machine command.
/// </summary>
/// <exception cref="ArgumentException"></exception>
[McpServerTool(Name = "vision_machine_command")]
[Description("Explicit OPC 40100-1 command: SelectAutomatic, Halt, Reset, Stop, Abort, StartSingleJob, " +
"StartContinuous, EnableSimulation, DisableSimulation, ConfirmAll, or Sync. " +
"Sync uses a step-model NodeId as systemNodeId. " +
"Returns the domain Error separately from service failures. " +
"Never retries or changes mode implicitly.")]
public static async Task<JsonElement> CommandAsync(
VisionClientAccessor accessor,
[Description("Part 1 system NodeId; for Sync, supply the step state-machine NodeId instead.")]
string systemNodeId,
[Description("SelectAutomatic, Halt, Reset, Stop, Abort, StartSingleJob, StartContinuous, " +
"EnableSimulation, DisableSimulation, ConfirmAll, or Sync; names are case-sensitive.")]
string command,
[Description("External recipe ID for job starts; must agree with " +
"parametersJson.ExternalRecipeId if supplied.")]
string? recipeId = null,
[Description("Product identifier for a job start; omit when selecting the recipe without a product.")]
string? productId = null,
[Description("Application measurement identifier for a job start; omission sends an empty identifier.")]
string? measurementId = null,
[Description("Application part identifier for a job start; omission sends an empty identifier.")]
string? partId = null,
[Description("Integer cause code for stop/reset/synchronization and simulation commands; default zero.")]
int cause = 0,
[Description("Human-readable command cause, or operator comment for ConfirmAll; default empty text.")]
string causeDescription = "",
[Description("Optional UA JSON object containing a Parameters Variant array and optionally an " +
"ExternalRecipeId (Id, Version, Hash, HashAlgorithm, Description), as written by JsonEncoder.")]
string? parametersJson = null,
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
ValidateInputJson(parametersJson, "Parameters");
VisionClient client = accessor.CreateClient(sessionName);
if (command == "Sync")
{
var step = new VisionStepModelStateMachineTypeClient(
client.Session, OpcUaJsonHelper.ParseNodeId(systemNodeId), client.Telemetry);
return Response(client, ("Error", Variant.From(await step.SyncAsync(cause, causeDescription, ct)
.ConfigureAwait(false))));
}
MachineVisionSystemClient system = System(client, systemNodeId);
VisionStateMachineTypeClient control = await system.ControlAsync(ct).ConfigureAwait(false);
if (command == "ConfirmAll")
{
await control.ConfirmAllAsync(new LocalizedText(causeDescription), ct).ConfigureAwait(false);
return Response(client, ("Error", Variant.From(0)));
}
if (command is "StartSingleJob" or "StartContinuous")
{
VisionAutomaticModeStateMachineTypeClient mode = await system.AutomaticAsync(ct).ConfigureAwait(false);
var measurement = new MeasIdDataType { Id = measurementId ?? string.Empty };
var part = new PartIdDataType { Id = partId ?? string.Empty };
var product = new ProductIdDataType { Id = productId ?? string.Empty };
using JsonDecoder? parameters = parametersJson == null
? null : new JsonDecoder(parametersJson, client.Session.MessageContext);
RecipeIdExternalDataType recipe =
parameters?.ReadEncodeable<RecipeIdExternalDataType>("ExternalRecipeId") ?? new();
if (!string.IsNullOrEmpty(recipeId))
{
if (!string.IsNullOrEmpty(recipe.Id) && recipe.Id != recipeId)
{
throw new ArgumentException("recipeId and ExternalRecipeId disagree.", nameof(recipeId));
}
recipe.Id = recipeId;
}
ArrayOf<Variant> values = default;
if (parameters is { } decoder)
{
values = decoder.ReadVariantArray("Parameters");
}
(JobIdDataType jobId, int error) result = command == "StartSingleJob"
? await mode.StartSingleJobAsync(measurement, part, recipe, product, values, ct)
.ConfigureAwait(false)
: await mode.StartContinuousAsync(measurement, part, recipe, product, values, ct)
.ConfigureAwait(false);
return Response(client, ("JobId", Variant.FromStructure(result.jobId)),
("Error", Variant.From(result.error)));
}
int error;
if (command == "SelectAutomatic")
{
error = await control.SelectModeAutomaticAsync(ct).ConfigureAwait(false);
}
else if (command == "Halt")
{
error = await control.HaltAsync(cause, causeDescription, ct).ConfigureAwait(false);
}
else if (command == "Reset")
{
error = await control.ResetAsync(cause, causeDescription, ct).ConfigureAwait(false);
}
else
{
VisionAutomaticModeStateMachineTypeClient mode = await system.AutomaticAsync(ct).ConfigureAwait(false);
error = command switch
{
"Stop" => await mode.StopAsync(cause, causeDescription, ct).ConfigureAwait(false),
"Abort" => await mode.AbortAsync(cause, causeDescription, ct).ConfigureAwait(false),
"EnableSimulation" => await mode.SimulationModeAsync(true, cause, causeDescription, ct)
.ConfigureAwait(false),
"DisableSimulation" => await mode.SimulationModeAsync(false, cause, causeDescription, ct)
.ConfigureAwait(false),
_ => throw new ArgumentException("Unknown Machine Vision command.", nameof(command))
};
}
return Response(client, ("Error", Variant.From(error)));
}
/// <summary>
/// Reads a bounded, namespace-qualified asset or management subtree.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException"></exception>
[McpServerTool(Name = "vision_machine_read_asset")]
[Description("Reads OPC 40100-2 identification, inventory, maintenance, health, " +
"and component-specific values. " +
"Can be rooted at an individual item or group. isComplete=false explicitly reports a depth/value bound. " +
"Returned NodeIds can be monitored with standard OPC UA subscriptions.")]
public static async Task<JsonElement> ReadAssetAsync(
VisionClientAccessor accessor,
[Description("Part 2 asset, component or group NodeId returned by discovery, such as ns=2;s=Camera.")]
string assetNodeId,
[Description("Maximum descendant traversal depth, from 0 to 32; default 5.")]
[Range(0, 32)] int maximumDepth = 5,
[Description("Maximum returned values, from 1 to 1024; default 128. Check isComplete for truncation.")]
[Range(1, 1024)] int maximumValues = 128,
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
if (maximumValues is <= 0 or > 1024)
{
throw new ArgumentOutOfRangeException(nameof(maximumValues));
}
VisionClient client = accessor.CreateClient(sessionName);
MachineVisionAssetSnapshot snapshot = await client.MachineVision().ReadAssetAsync(
OpcUaJsonHelper.ParseNodeId(assetNodeId), maximumDepth, maximumValues, ct).ConfigureAwait(false);
var values = new JsonArray();
foreach (MachineVisionAssetValue value in snapshot.Values)
{
var path = new JsonArray();
foreach (QualifiedName element in value.BrowsePath)
{
AddNode(path, JsonValue.Create(element.ToString()));
}
using var encoder = new JsonEncoder(client.Session.MessageContext);
encoder.WriteDataValue("DataValue", value.Value);
AddNode(values, new JsonObject
{
["nodeId"] = value.NodeId.ToString(),
["path"] = path,
["typeDefinition"] = value.TypeDefinition.ToString(),
["reading"] = JsonNode.Parse(encoder.CloseAndReturnText())
});
}
return Element(new JsonObject { ["isComplete"] = snapshot.IsComplete, ["values"] = values });
}
/// <summary>
/// Executes one recipe/product metadata operation.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException"></exception>
/// <exception cref="ArgumentException"></exception>
[McpServerTool(Name = "vision_machine_recipes")]
[Description("OPC 40100-1 recipe/product operation: List, Add, Prepare, Unprepare, Remove, PrepareProduct, " +
"UnprepareProduct, UnlinkProduct, or Release. List is bounded to 1-100 records. " +
"Add only registers metadata; transferRequired=true requires the file tool before preparation. " +
"Error is the domain result, not a service status.")]
public static async Task<JsonElement> RecipesAsync(
VisionClientAccessor accessor,
[Description("Part 1 system NodeId returned by vision_machine_list, such as ns=2;s=VisionSystem.")]
string systemNodeId,
[Description("List, Add, Prepare, Unprepare, Remove, PrepareProduct, UnprepareProduct, UnlinkProduct, " +
"or Release; names are case-sensitive.")]
string operation,
[Description("External recipe ID for Add/Prepare/Unprepare/Remove, or a recipe-ID filter for List.")]
string externalId = "",
[Description("Server-assigned recipe ID for Prepare, Unprepare or UnlinkProduct; not a NodeId.")]
string internalId = "",
[Description("Product ID for recipe linking, product preparation/unpreparation, or filtering List.")]
string productId = "",
[Description("External recipe version used with externalId; empty leaves the version unspecified.")]
string version = "",
[Description("Maximum recipe records returned by List, from 1 to 100; default 50.")]
[Range(1, 100)] uint maximumResults = 50,
[Description("Zero-based recipe offset for List; default zero starts the first page.")]
uint startIndex = 0,
[Description("Recipe metadata handle returned by List; pass it to Release when finished.")]
uint handle = 0,
[Description("Prepared-state filter for List: 0=False, 1=True, 2=DontCare; default 2.")]
[Range(0, 2)] int preparedState = 2,
[Description("Optional UA JSON recipe identifier including " +
"Id, Version, Hash, HashAlgorithm, and Description.")]
string? identifierJson = null,
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
ValidatePage(maximumResults);
ValidateInputJson(identifierJson);
if (preparedState is < 0 or > 2)
{
throw new ArgumentOutOfRangeException(nameof(preparedState));
}
VisionClient client = accessor.CreateClient(sessionName);
RecipeManagementTypeClient recipes = await System(client, systemNodeId).RecipesAsync(ct)
.ConfigureAwait(false);
RecipeIdExternalDataType external = Identifier<RecipeIdExternalDataType>(
client, externalId, version, identifierJson);
var internalIdentifier = new RecipeIdInternalDataType { Id = internalId };
var product = new ProductIdDataType { Id = productId };
switch (operation)
{
case "List":
(bool isComplete, uint resultCount, uint recipeHandle,
ArrayOf<RecipeIdInternalDataType> recipeList, int error) = await recipes
.GetRecipeListFilteredAsync(external, product, (TriStateBooleanDataType)preparedState,
maximumResults, startIndex, -1, ct)
.ConfigureAwait(false);
return Response(client, ("IsComplete", Variant.From(isComplete)),
("ResultCount", Variant.From(resultCount)), ("Handle", Variant.From(recipeHandle)),
("Recipes", Variant.FromStructure(recipeList)), ("Error", Variant.From(error)));
case "Add":
(RecipeIdInternalDataType internalId, NodeId recipe, NodeId product,
bool transferRequired, int error) added =
await recipes.AddRecipeAsync(external, product, ct).ConfigureAwait(false);
return Response(client, ("InternalId", Variant.FromStructure(added.internalId)),
("RecipeNodeId", Variant.From(added.recipe)), ("ProductNodeId", Variant.From(added.product)),
("TransferRequired", Variant.From(added.transferRequired)),
("Error", Variant.From(added.error)));
case "Prepare":
(RecipeIdInternalDataType internalIdOut, bool isCompleted, int error) prepared =
await recipes.PrepareRecipeAsync(external, internalIdentifier, ct).ConfigureAwait(false);
return Response(client, ("InternalId", Variant.FromStructure(prepared.internalIdOut)),
("IsCompleted", Variant.From(prepared.isCompleted)), ("Error", Variant.From(prepared.error)));
case "Unprepare":
(RecipeIdInternalDataType internalIdOut, int error) unprepared =
await recipes.UnprepareRecipeAsync(external, internalIdentifier, ct).ConfigureAwait(false);
return Response(client, ("InternalId", Variant.FromStructure(unprepared.internalIdOut)),
("Error", Variant.From(unprepared.error)));
case "PrepareProduct":
(RecipeIdInternalDataType internalId, int error) prepareProduct =
await recipes.PrepareProductAsync(product, ct).ConfigureAwait(false);
return Response(client, ("InternalId", Variant.FromStructure(prepareProduct.internalId)),
("Error", Variant.From(prepareProduct.error)));
case "UnprepareProduct":
(RecipeIdInternalDataType internalId, int error) unprepareProduct =
await recipes.UnprepareProductAsync(product, ct).ConfigureAwait(false);
return Response(client, ("InternalId", Variant.FromStructure(unprepareProduct.internalId)),
("Error", Variant.From(unprepareProduct.error)));
case "Remove":
return Response(client, ("Error",
Variant.From(await recipes.RemoveRecipeAsync(external, ct).ConfigureAwait(false))));
case "UnlinkProduct":
return Response(client, ("Error", Variant.From(
await recipes.UnlinkProductAsync(internalIdentifier, product, ct).ConfigureAwait(false))));
case "Release":
return Response(client, ("Error",
Variant.From(await recipes.ReleaseRecipeHandleAsync(handle, ct).ConfigureAwait(false))));
default:
throw new ArgumentException("Unknown recipe operation.", nameof(operation));
}
}
/// <summary>
/// Executes one configuration metadata operation.
/// </summary>
/// <exception cref="ArgumentException"></exception>
[McpServerTool(Name = "vision_machine_configurations")]
[Description("OPC 40100-1 configuration operation: List, Get, Add, Activate, Remove, or Release. " +
"Add registers metadata only; use the file tool when transferRequired is true. " +
"Activation is never implicit.")]
public static async Task<JsonElement> ConfigurationsAsync(
VisionClientAccessor accessor,
[Description("Part 1 system NodeId returned by vision_machine_list, such as ns=2;s=VisionSystem.")]
string systemNodeId,
[Description("List, Get, Add, Activate, Remove, or Release; operation names are case-sensitive.")]
string operation,
[Description("Configuration ID for Get/Add/Activate/Remove, unless the ID is supplied in identifierJson.")]
string id = "",
[Description("Configuration version used with id; empty leaves the version unspecified.")]
string version = "",
[Description("Maximum configuration records returned by List, from 1 to 100; default 50.")]
[Range(1, 100)] uint maximumResults = 50,
[Description("Zero-based configuration offset for List; default zero starts the first page.")]
uint startIndex = 0,
[Description("Configuration metadata handle returned by List or Get; pass it to Release when finished.")]
uint handle = 0,
[Description("Optional UA JSON configuration identifier including " +
"Id, Version, Hash, HashAlgorithm, and Description.")]
string? identifierJson = null,
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
ValidatePage(maximumResults);
ValidateInputJson(identifierJson);
VisionClient client = accessor.CreateClient(sessionName);
ConfigurationManagementTypeClient configurations = await System(client, systemNodeId)
.ConfigurationsAsync(ct).ConfigureAwait(false);
ConfigurationIdDataType identifier =
Identifier<ConfigurationIdDataType>(client, id, version, identifierJson);
switch (operation)
{
case "List":
(bool isComplete, uint resultCount, uint configurationHandle,
ArrayOf<ConfigurationDataType> configurationList, int error)
= await configurations.GetConfigurationListAsync(maximumResults, startIndex, -1, ct)
.ConfigureAwait(false);
return Response(client, ("IsComplete", Variant.From(isComplete)),
("ResultCount", Variant.From(resultCount)),
("Handle", Variant.From(configurationHandle)),
("Configurations", Variant.FromStructure(configurationList)),
("Error", Variant.From(error)));
case "Get":
(uint configurationHandle, ConfigurationDataType configuration, int error) found =
await configurations.GetConfigurationByIdAsync(identifier, -1, ct).ConfigureAwait(false);
return Response(client, ("Handle", Variant.From(found.configurationHandle)),
("Configuration", Variant.FromStructure(found.configuration)),
("Error", Variant.From(found.error)));
case "Add":
(ConfigurationIdDataType internalId, NodeId configuration,
bool transferRequired, int error) added =
await configurations.AddConfigurationAsync(identifier, ct).ConfigureAwait(false);
return Response(client, ("InternalId", Variant.FromStructure(added.internalId)),
("ConfigurationNodeId", Variant.From(added.configuration)),
("TransferRequired", Variant.From(added.transferRequired)),
("Error", Variant.From(added.error)));
case "Activate":
return Response(client, ("Error", Variant.From(
await configurations.ActivateConfigurationAsync(identifier, ct).ConfigureAwait(false))));
case "Remove":
return Response(client, ("Error", Variant.From(
await configurations.RemoveConfigurationAsync(identifier, ct).ConfigureAwait(false))));
case "Release":
return Response(client, ("Error", Variant.From(
await configurations.ReleaseConfigurationHandleAsync(handle, ct).ConfigureAwait(false))));
default:
throw new ArgumentException("Unknown configuration operation.", nameof(operation));
}
}
/// <summary>
/// Reads or releases result metadata.
/// </summary>
/// <exception cref="ArgumentException"></exception>
[McpServerTool(Name = "vision_machine_results")]
[Description("OPC 40100-1 result operation: Get, Components, List, or Release. " +
"Lists are paged and support all identity filters through filtersJson. " +
"Returns complete typed OPC UA metadata/content and the handle to release.")]
public static async Task<JsonElement> ResultsAsync(
VisionClientAccessor accessor,
[Description("Part 1 system NodeId returned by vision_machine_list, such as ns=2;s=VisionSystem.")]
string systemNodeId,
[Description("Get, Components, List, or Release; operation names are case-sensitive.")]
string operation,
[Description("Result ID returned by List; Get and Components use this ID, not its NodeId.")]
string resultId = "",
[Description("Job ID filter for List; must match filtersJson.JobId.Id if both selectors are supplied.")]
string jobId = "",
[Description("Integer result-state filter for List, forwarded unchanged to the server; default zero.")]
int resultState = 0,
[Description("Maximum result records returned by List, from 1 to 100; default 50.")]
[Range(1, 100)] uint maximumResults = 50,
[Description("Zero-based result offset for List; default zero starts the first page.")]
uint startIndex = 0,
[Description("Metadata handle returned by Get, Components or List; pass it to Release when finished.")]
uint handle = 0,
[Description("Optional UA JSON object with MeasId, PartId, ExternalRecipeId, InternalRecipeId, " +
"ExternalConfigurationId, InternalConfigurationId, ProductId, and JobId structures. " +
"Binary identifiers retain Version, Hash, HashAlgorithm, and Description.")]
string? filtersJson = null,
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
ValidatePage(maximumResults);
ValidateInputJson(filtersJson);
VisionClient client = accessor.CreateClient(sessionName);
ResultManagementTypeClient results = await System(client, systemNodeId).ResultsAsync(ct)
.ConfigureAwait(false);
if (operation == "Release")
{
return Response(client, ("Error", Variant.From(await results.ReleaseResultHandleAsync(handle, ct)
.ConfigureAwait(false))));
}
if (operation == "Get")
{
(uint resultHandle, ResultDataType result, int error) =
await results.GetResultByIdAsync(new ResultIdDataType { Id = resultId }, -1, ct)
.ConfigureAwait(false);
return Response(client, ("Handle", Variant.From(resultHandle)),
("Result", Variant.FromStructure(result)), ("Error", Variant.From(error)));
}
if (operation == "Components")
{
(bool hasTransferableDataOnFile, uint resultHandle, bool isPartial, bool isSimulated,
int resultState, MeasIdDataType measId, PartIdDataType partId,
RecipeIdExternalDataType externalRecipeId, RecipeIdInternalDataType internalRecipeId,
ProductIdDataType productId, ConfigurationIdDataType externalConfigurationId,
ConfigurationIdDataType internalConfigurationId, JobIdDataType jobId, DateTimeUtc creationTime,
ProcessingTimesDataType processingTimes, ArrayOf<Variant> resultContent, int error) found =
await results.GetResultComponentsByIdAsync(new ResultIdDataType { Id = resultId }, -1, ct)
.ConfigureAwait(false);
return Response(client, ("Handle", Variant.From(found.resultHandle)),
("HasTransferableDataOnFile", Variant.From(found.hasTransferableDataOnFile)),
("IsPartial", Variant.From(found.isPartial)), ("IsSimulated", Variant.From(found.isSimulated)),
("ResultState", Variant.From(found.resultState)),
("MeasId", Variant.FromStructure(found.measId)), ("PartId", Variant.FromStructure(found.partId)),
("ExternalRecipeId", Variant.FromStructure(found.externalRecipeId)),
("InternalRecipeId", Variant.FromStructure(found.internalRecipeId)),
("ExternalConfigurationId", Variant.FromStructure(found.externalConfigurationId)),
("InternalConfigurationId", Variant.FromStructure(found.internalConfigurationId)),
("ProductId", Variant.FromStructure(found.productId)),
("JobId", Variant.FromStructure(found.jobId)),
("CreationTime", Variant.From(found.creationTime)),
("ProcessingTimes", Variant.FromStructure(found.processingTimes)),
("ResultContent", Variant.From(found.resultContent)), ("Error", Variant.From(found.error)));
}
if (operation != "List")
{
throw new ArgumentException("Unknown result operation.", nameof(operation));
}
using JsonDecoder? filters = filtersJson == null
? null : new JsonDecoder(filtersJson, client.Session.MessageContext);
JobIdDataType job = filters?.ReadEncodeable<JobIdDataType>("JobId") ?? new JobIdDataType();
if (!string.IsNullOrEmpty(jobId))
{
if (!string.IsNullOrEmpty(job.Id) && job.Id != jobId)
{
throw new ArgumentException("The jobId and filtersJson JobId disagree.", nameof(jobId));
}
job.Id = jobId;
}
(bool isComplete, uint resultCount, uint resultHandle,
ArrayOf<ResultDataType> resultList, int error) list =
await results.GetResultListFilteredAsync(resultState,
filters?.ReadEncodeable<MeasIdDataType>("MeasId") ?? new MeasIdDataType(),
filters?.ReadEncodeable<PartIdDataType>("PartId") ?? new PartIdDataType(),
filters?.ReadEncodeable<RecipeIdExternalDataType>("ExternalRecipeId") ??
new RecipeIdExternalDataType(),
filters?.ReadEncodeable<RecipeIdInternalDataType>("InternalRecipeId") ??
new RecipeIdInternalDataType(),
filters?.ReadEncodeable<ConfigurationIdDataType>("ExternalConfigurationId") ??
new ConfigurationIdDataType(),
filters?.ReadEncodeable<ConfigurationIdDataType>("InternalConfigurationId") ??
new ConfigurationIdDataType(),
filters?.ReadEncodeable<ProductIdDataType>("ProductId") ?? new ProductIdDataType(),
job, maximumResults, startIndex, -1, ct)
.ConfigureAwait(false);
return Response(client, ("IsComplete", Variant.From(list.isComplete)),
("ResultCount", Variant.From(list.resultCount)), ("Handle", Variant.From(list.resultHandle)),
("Results", Variant.FromStructure(list.resultList)), ("Error", Variant.From(list.error)));
}
/// <summary>
/// Reports safety information without acting as a safety controller.
/// </summary>
[McpServerTool(Name = "vision_machine_report_safety")]
[Description("Reports OPC 40100-1 safety information and raises its event. " +
"This does not implement a safety controller " +
"and must not replace a certified safety circuit.")]
public static async Task<JsonElement> ReportSafetyAsync(
VisionClientAccessor accessor,
[Description("Part 1 system NodeId returned by vision_machine_list, such as ns=2;s=VisionSystem.")]
string systemNodeId,
[Description("True reports a triggered external safety input; this does not command a safety circuit.")]
bool triggered,
[Description("Human-readable information describing the reported external safety state.")]
string information,
[Description("Connected OPC UA session name; omit when exactly one session is available.")]
string? sessionName = null,
CancellationToken ct = default)
{
VisionClient client = accessor.CreateClient(sessionName);
SafetyStateManagementTypeClient safety = await System(client, systemNodeId).SafetyAsync(ct)
.ConfigureAwait(false);
return Response(client, ("Error",
Variant.From(await safety.ReportSafetyStateAsync(triggered, information, ct).ConfigureAwait(false))));
}
internal static MachineVisionSystemClient System(VisionClient client, string id)
{
return client.MachineVision().System(OpcUaJsonHelper.ParseNodeId(id));
}
internal static JsonElement Response(VisionClient client, params (string Name, Variant Value)[] fields)
{
using var encoder = new JsonEncoder(client.Session.MessageContext);
foreach ((string name, Variant value) in fields)
{
encoder.WriteVariant(name, value);
}
return ParseResponse(encoder.CloseAndReturnText());
}
private static JsonElement Element(JsonNode value)
{
return ParseResponse(value.ToJsonString());
}
private static void AddNode(JsonArray array, JsonNode? value)
{
array.Add(value);
}
private static JsonElement ParseResponse(string json)
{
if (Encoding.UTF8.GetByteCount(json) > 2 * 1024 * 1024)
{
throw ServiceResultException.Create(
StatusCodes.BadEncodingLimitsExceeded,
"The MCP response is too large; use a smaller page, a narrower asset root, or file chunks.");
}
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
private static JsonObject State(FiniteStateSnapshot state)
{
return new JsonObject
{
["state"] = state.CurrentState.Text,
["stateId"] = state.CurrentStateId.ToString(),
["lastTransition"] = state.LastTransition.Text,
["status"] = state.Status.ToString()
};
}
private static void ValidatePage(uint maximum)
{
if (maximum is 0 or > 100)
{
throw new ArgumentOutOfRangeException(nameof(maximum), "MCP pages contain between 1 and 100 items.");
}
}
private static T Identifier<T>(VisionClient client, string id, string version, string? json)
where T : BinaryIdBaseDataType, new()
{
using JsonDecoder? decoder = json == null ? null : new JsonDecoder(json, client.Session.MessageContext);
var identifier = new T();
if (decoder != null)
{
identifier.Decode(decoder);
}
if (!string.IsNullOrEmpty(id))
{
if (!string.IsNullOrEmpty(identifier.Id) && identifier.Id != id)
{
throw new ArgumentException(
"The identifier's Id disagrees with the supplied selector.", nameof(id));
}
identifier.Id = id;
}
if (!string.IsNullOrEmpty(version))
{
if (!string.IsNullOrEmpty(identifier.Version) && identifier.Version != version)
{
throw new ArgumentException(
"The identifier's Version disagrees with the supplied selector.", nameof(version));
}
identifier.Version = version;
identifier.EncodingMask |= (uint)BinaryIdBaseDataTypeFields.Version;
}
return identifier;
}
private static void ValidateInputJson(string? json, string? arrayProperty = null)
{
if (json == null)
{
return;
}
if (Encoding.UTF8.GetByteCount(json) > 1024 * 1024)
{
throw new ArgumentOutOfRangeException(nameof(json), "A structured MCP input cannot exceed 1 MiB.");
}
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
throw new ArgumentException("A UA JSON object is required.", nameof(json));
}
if (arrayProperty != null &&
(!document.RootElement.TryGetProperty(arrayProperty, out JsonElement array) ||
array.ValueKind != JsonValueKind.Array))
{
throw new ArgumentException("The UA JSON object must contain a Parameters array.", nameof(json));
}
}
}
}
@@ -27,15 +27,14 @@
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.ComponentModel;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Opc.Ua.Vision;
using Opc.Ua.Vision.Client;
using Opc.Ua.Vision.Perception;
namespace Opc.Ua.Mcp.Tools
{
@@ -46,8 +46,31 @@ namespace Opc.Ua.Mcp
/// Initializes the accessor.
/// </summary>
public VisionClientAccessor(OpcUaSessionManager sessionManager)
: this(sessionManager, TimeProvider.System)
{
}
/// <summary>
/// Initializes the accessor with an injectable workflow clock.
/// </summary>
public VisionClientAccessor(OpcUaSessionManager sessionManager, TimeProvider timeProvider)
{
m_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
TimeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <summary>
/// Gets the clock used by bounded workflows.
/// </summary>
public TimeProvider TimeProvider { get; }
/// <summary>
/// Opens the released-model interface on a named session using the configured clock.
/// </summary>
public MachineVisionClient CreateMachineVisionClient(string? sessionName = null)
{
return new MachineVisionClient(m_sessionManager.GetSessionOrThrow(sessionName),
m_sessionManager.Telemetry, TimeProvider);
}
/// <summary>
@@ -324,7 +324,9 @@ namespace Opc.Ua.SourceGeneration
/// shadowing). Method names are emitted as
/// <c>{methodSymbolicName}Async</c>.
/// </summary>
private HashSet<string> CollectInheritedMethodNames(ObjectTypeDesign objectType)
private HashSet<string> CollectInheritedMethodNames(
ObjectTypeDesign objectType,
bool includeSignature = false)
{
var names = new HashSet<string>(StringComparer.Ordinal);
ObjectTypeDesign? parent = GetEmittedBaseType(objectType);
@@ -335,7 +337,7 @@ namespace Opc.Ua.SourceGeneration
string? name = method.SymbolicName?.Name;
if (!string.IsNullOrEmpty(name))
{
names.Add(name + "Async");
names.Add(includeSignature ? GetMethodSignature(method) : name + "Async");
}
}
parent = GetEmittedBaseType(parent);
@@ -343,6 +345,25 @@ namespace Opc.Ua.SourceGeneration
return names;
}
/// <summary>
/// Identifies C# method hiding by input types, not just by the OPC UA
/// method name. A narrowed generate-options argument creates an overload.
/// </summary>
private string GetMethodSignature(MethodDesign method)
{
MethodDesign effective = method.IsOverridden()
? (MethodDesign)method.GetMergedInstance()
: method;
Parameter[] inputs = MethodDesignArgumentResolver.ResolveMethodInputs(effective);
return method.SymbolicName.Name + "(" + string.Join(
",",
Array.ConvertAll(inputs, input => input.DataTypeNode!.GetMethodArgumentTypeAsCode(
input.ValueRank,
m_context.ModelDesign.TargetNamespace.Value,
m_context.ModelDesign.Namespaces,
input.IsOptional))) + ")";
}
/// <summary>
/// Walks the supertype chain and returns the union of the
/// emitted C# accessor names for Object children declared
@@ -386,6 +407,7 @@ namespace Opc.Ua.SourceGeneration
// Stash inherited method names so LoadTemplate_Method can
// emit the C# 'new' modifier on shadowed methods.
m_inheritedMethodNames = CollectInheritedMethodNames(objectType);
m_inheritedMethodSignatures = CollectInheritedMethodNames(objectType, includeSignature: true);
// And inherited Object child names so the Object accessor
// emitter does the same.
m_inheritedObjectChildNames = CollectInheritedObjectChildNames(objectType);
@@ -650,8 +672,8 @@ namespace Opc.Ua.SourceGeneration
string returnTypeAnnotation = GetReturnTypeAnnotation(
outputs, targetNamespace, namespaces);
bool isShadow = m_inheritedMethodNames != null &&
m_inheritedMethodNames.Contains(methodName + "Async");
bool isShadow = m_inheritedMethodSignatures != null &&
m_inheritedMethodSignatures.Contains(GetMethodSignature(method));
// XML doc and signature
context.Out.WriteLine();
@@ -1052,6 +1074,7 @@ namespace Opc.Ua.SourceGeneration
private readonly IGeneratorContext m_context;
private readonly Microsoft.Extensions.Logging.ILogger m_logger;
private HashSet<string>? m_inheritedMethodNames;
private HashSet<string>? m_inheritedMethodSignatures;
private HashSet<string>? m_inheritedAccessorNames;
private HashSet<string>? m_inheritedObjectChildNames;
}
@@ -5834,8 +5834,13 @@ namespace Opc.Ua.Schema.Model
string basePath,
Dictionary<string, InstanceDesign> nodes,
int depth,
bool fromInstance)
bool fromInstance,
HashSet<XmlQualifiedName> typePath)
{
if (!typePath.Add(type.SymbolicId))
{
throw new InvalidOperationException($"Cyclic base type: {type.SymbolicId}.");
}
if (type.BaseTypeNode != null)
{
SetOverriddenNodes(
@@ -5843,7 +5848,8 @@ namespace Opc.Ua.Schema.Model
basePath,
nodes,
depth + 1,
fromInstance);
fromInstance,
typePath);
}
if (type.Children != null && type.Children.Items != null)
@@ -5866,7 +5872,7 @@ namespace Opc.Ua.Schema.Model
continue;
}
SetOverriddenNodes(instance, browsePath, nodes, depth + 1);
SetOverriddenNodes(instance, browsePath, nodes, depth + 1, typePath);
if (nodes.TryGetValue(browsePath, out InstanceDesign? overriddenInstance))
{
@@ -5920,13 +5926,15 @@ namespace Opc.Ua.Schema.Model
nodes[browsePath] = instance;
}
}
typePath.Remove(type.SymbolicId);
}
private void SetOverriddenNodes(
InstanceDesign parent,
string basePath,
Dictionary<string, InstanceDesign> nodes,
int depth)
int depth,
HashSet<XmlQualifiedName> typePath)
{
if (depth > MaxRecursionDepth)
{
@@ -5934,14 +5942,15 @@ namespace Opc.Ua.Schema.Model
$"{parent.SymbolicId.Name} max recursion exceeded. Check model.");
}
if (parent.TypeDefinitionNode != null)
if (parent.TypeDefinitionNode != null && ShouldExpandType(parent, typePath))
{
SetOverriddenNodes(
parent.TypeDefinitionNode,
basePath,
nodes,
depth + 1,
true);
true,
typePath);
}
if (parent.Children != null && parent.Children.Items != null)
@@ -5964,7 +5973,7 @@ namespace Opc.Ua.Schema.Model
continue;
}
SetOverriddenNodes(instance, browsePath, nodes, depth + 1);
SetOverriddenNodes(instance, browsePath, nodes, depth + 1, typePath);
if (nodes.TryGetValue(browsePath, out InstanceDesign? overriddenInstance))
{
@@ -6010,17 +6019,94 @@ namespace Opc.Ua.Schema.Model
}
var nodes = new Dictionary<string, InstanceDesign>();
var typePath = new HashSet<XmlQualifiedName>();
switch (node)
{
case TypeDesign type:
SetOverriddenNodes(type, string.Empty, nodes, depth + 1, false);
SetOverriddenNodes(type, string.Empty, nodes, depth + 1, false, typePath);
break;
case InstanceDesign instance:
SetOverriddenNodes(instance, string.Empty, nodes, depth + 1);
SetOverriddenNodes(instance, string.Empty, nodes, depth + 1, typePath);
break;
}
}
/// <summary>
/// Stops implicit expansion at a recursive optional declaration while retaining
/// its explicitly declared children. Mandatory recursion has no finite instance.
/// </summary>
private static bool ShouldExpandType(
InstanceDesign instance,
HashSet<XmlQualifiedName> typePath)
{
if (instance.TypeDefinitionNode == null)
{
return false;
}
if (!typePath.Contains(instance.TypeDefinitionNode.SymbolicId))
{
return !IsOptionalExpansion(instance) ||
!HasMandatoryPathToType(instance.TypeDefinitionNode, typePath);
}
if (IsOptionalExpansion(instance))
{
return false;
}
throw new InvalidOperationException(
$"Recursive mandatory instance '{instance.SymbolicId}' has no finite expansion.");
}
private static bool IsOptionalExpansion(InstanceDesign instance)
{
return instance.ModellingRule is ModellingRule.Optional or
ModellingRule.OptionalPlaceholder or ModellingRule.MandatoryPlaceholder;
}
/// <summary>
/// Finds a mandatory return path before entering an optional edge, so a finite
/// A-optional-B-mandatory-A cycle is cut at the optional declaration, not at required A children.
/// </summary>
private static bool HasMandatoryPathToType(TypeDesign root, HashSet<XmlQualifiedName> targets)
{
var pending = new Queue<NodeDesign>();
var visited = new HashSet<XmlQualifiedName>();
pending.Enqueue(root);
while (pending.Count != 0)
{
NodeDesign node = pending.Dequeue();
if (!visited.Add(node.SymbolicId))
{
continue;
}
if (node is TypeDesign type)
{
if (targets.Contains(type.SymbolicId))
{
return true;
}
if (type.BaseTypeNode != null)
{
pending.Enqueue(type.BaseTypeNode);
}
}
if (node is InstanceDesign { TypeDefinitionNode: { } definition })
{
pending.Enqueue(definition);
}
if (node.Children?.Items != null)
{
foreach (InstanceDesign child in node.Children.Items)
{
if (!IsOptionalExpansion(child))
{
pending.Enqueue(child);
}
}
}
}
return false;
}
private void TranslateReferences(
string currentPath,
NodeDesign source,
@@ -6237,7 +6323,8 @@ namespace Opc.Ua.Schema.Model
List<HierarchyReference> references,
bool suppressInverseHierarchicalAtTypeLevel,
bool inherited,
int depth)
int depth,
HashSet<XmlQualifiedName> typePath)
{
if (m_logger.IsEnabled(LogLevel.Debug))
{
@@ -6253,6 +6340,10 @@ namespace Opc.Ua.Schema.Model
$"{basePath} max recursion exceeded. Check model.");
}
if (!typePath.Add(type.SymbolicId))
{
throw new InvalidOperationException($"Cyclic base type: {type.SymbolicId}.");
}
if (type.BaseTypeNode != null &&
type is VariableTypeDesign or ObjectTypeDesign)
{
@@ -6263,7 +6354,8 @@ namespace Opc.Ua.Schema.Model
references,
true,
true,
depth + 1);
depth + 1,
typePath);
}
TranslateReferences(
@@ -6327,9 +6419,11 @@ namespace Opc.Ua.Schema.Model
nodes,
references,
inherited,
depth + 1);
depth + 1,
typePath);
}
}
typePath.Remove(type.SymbolicId);
}
/// <summary>
@@ -6342,7 +6436,8 @@ namespace Opc.Ua.Schema.Model
List<HierarchyNode> nodes,
List<HierarchyReference> references,
bool inherited,
int depth)
int depth,
HashSet<XmlQualifiedName> typePath)
{
if (m_logger.IsEnabled(LogLevel.Debug))
{
@@ -6358,7 +6453,7 @@ namespace Opc.Ua.Schema.Model
$"{basePath} max recursion exceeded. Check model.");
}
if (parent.TypeDefinitionNode != null)
if (parent.TypeDefinitionNode != null && ShouldExpandType(parent, typePath))
{
BuildInstanceHierarchy(
parent.TypeDefinitionNode,
@@ -6367,7 +6462,8 @@ namespace Opc.Ua.Schema.Model
references,
true,
inherited,
depth + 1);
depth + 1,
typePath);
}
if (parent.TypeDefinition != null && parent is MethodDesign method)
@@ -6386,7 +6482,8 @@ namespace Opc.Ua.Schema.Model
nodes,
references,
inherited,
depth + 1);
depth + 1,
typePath);
}
}
@@ -6468,7 +6565,8 @@ namespace Opc.Ua.Schema.Model
nodes,
references,
inherited,
depth + 1);
depth + 1,
typePath);
}
}
}
@@ -6499,7 +6597,8 @@ namespace Opc.Ua.Schema.Model
references,
false,
false,
depth + 1);
depth + 1,
[]);
break;
case InstanceDesign instance:
BuildInstanceHierarchy(
@@ -6508,7 +6607,8 @@ namespace Opc.Ua.Schema.Model
nodes,
references,
false,
depth + 1);
depth + 1,
[]);
break;
}
}
@@ -2336,7 +2336,8 @@ namespace Opc.Ua.Schema.Model
foreach (InstanceDesign child in type.Children.Items)
{
if (child.BrowseName == type.ClassName)
if (child.BrowseName == type.ClassName ||
child.SymbolicName.Name == type.ClassName + "State")
{
type.ClassName = className +
(type is VariableTypeDesign ? "Variable" : "Object");