# Description Nullable reference types are now enabled once, in `common.props`, and every project in the repository builds warning-clean with them. **Configuration** - `<Nullable>enable</Nullable>` lives in `common.props`. All per-project `<Nullable>` settings are removed: 178 project files (enable, annotations, and the `disable` in the three Aot test projects). - 419 per-file `#nullable enable`/`restore` directives and the 4 `#nullable disable` directives are removed. - Kept on purpose: - The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`, `PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the project setting in generated code, so the directive is required there. - `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against the 1.5.378 packages and does not import the repository props. - `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now describe the central setting. **Migration** (the 43 unset and 13 annotations-only projects, roughly 10k diagnostics) - **Production code: annotated at the declarations.** Covers `tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the LDS sample. - Members, parameters and returns are nullable where null actually flows. - Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`, `IsMethodTypeDesign`). - `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take `params object?[]`. `Template.AddReplacement` takes `string?` (rendering already skips null replacements). - `IRegisteredServerStore.RegisterAsync` returns `Task<RegistrationEntry?>`: it returns null when a registration is removed. - Dereferences backed by an invariant (for example, the validated model) use `!`, so runtime behaviour is unchanged. - **Behavioural changes in production code** (small, deliberate): - `NodeSetToModelDesign` throws `InvalidOperationException` when the file is not a NodeSet. Before, it threw a NullReferenceException on the next line. - `ModelDesignValidator` starts its lookup tables empty instead of null. A redundant `if (m_nodes == null) return;` guard is gone. - **Test code: minimal annotations.** - `?` on values that can be null, `!` where the test asserts non-null. - `= null!` for fixture members assigned in SetUp. - `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the `IEncodeable` constraint. - A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`) folded into the adjacent `IsNull`/`Count` checks. **Notes for reviewers** - The diff is large (~1,500 files) but almost entirely mechanical: `?`, `!` and directive removal. The non-annotation changes are listed above. - Known follow-up, not done here: `IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay non-nullable even though the validator returns null before it loads a dictionary. Making them nullable would ripple through every generator call site. ## Related Issues - Fixes #4439 ## Checklist - [ ] 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. - [ ] 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. - [ ] I have addressed **all** PR feedback received. The "no new warnings" box covers net10.0 and net48: full non-incremental builds of `UA.slnx`, `tools/SourceGeneration.slnx`, `tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0 warnings and 0 errors under the repository's warnings-as-errors settings. net9.0 and net8.0 also had no nullable diagnostics, but they were not rebuilt after the final fixes. Tests have not been run locally yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Robot Intent Viewer Client
This sample connects to IntentEnabledRobot, discovers its Robot Intent controller, prints the facets declared by its capabilities, obtains command authority, and maps OpenUSD target picks to Robot Intent linear moves. With --mcp on a supported target framework, it also hosts the Robot Intent MCP tools over the same OPC UA session. A submitted intent returns admission immediately; this client then waits on the returned operation and prints live ExecutionState, Progress, CurrentPose, and the terminal IntentResultDataType.
MCP hosting
With --mcp, the viewer client exposes the Robotics MCP profile so an LLM agent can discover controllers, read state, request authority, submit intents and missions, pause/resume/cancel/retry operations, and wait for operation completion while a human watches the same robot in the OpenUSD viewport. The MCP tools use the one SampleSession opened by the sample; the viewer, OpenUSD live bindings, and MCP tools therefore observe and command the same connected controller.
MCP hosting is compiled for net8.0, net9.0, and net10.0. It is unavailable on net48; running that leg with --mcp reports the limitation on stderr. Without --mcp, the sample continues to run on every target framework it builds for.
MCP options:
--mcpenables MCP hosting.--transport stdio|http|sseselects the MCP transport.sseis accepted as an alias forhttp.--port <number>selects the Streamable HTTP port. The default is5100.
Transport selection is visible on stderr:
- No
--transportand no--view: stdio is selected by default. - No
--transportwith--view: HTTP is selected automatically and the client explains that MCP stdio uses stdout for protocol frames, which cannot safely coexist with the in-process OpenUSD viewer. --transport httpwith or without--view: HTTP is used.--transport stdiowithout--view: stdio is used.--transport stdio --view: the explicit request is honored, but the client warns plainly that the viewer may share stdout and corrupt the MCP stdio protocol.
Example MCP client configuration for viewport mode:
{
"servers": {
"intent-viewer": {
"url": "http://localhost:5100/mcp"
}
}
}
Run the sample with HTTP MCP and the viewport:
dotnet run --project samples\Robotics\IntentViewerClient\IntentViewerClient.csproj --framework net10.0 -- --server opc.tcp://localhost:62840/IntentEnabledRobot --insecure --view --mcp --transport http --port 5100 --fetch-assets .\artifacts\intent-viewer-stage
For stdio MCP, point the MCP client at the command instead:
{
"servers": {
"intent-viewer": {
"command": "dotnet",
"args": [
"run",
"--project",
"samples\\Robotics\\IntentViewerClient\\IntentViewerClient.csproj",
"--",
"--server",
"opc.tcp://localhost:62840/IntentEnabledRobot",
"--insecure",
"--mcp"
]
}
}
}
Agents can submit only what the server advertises and accepts. Refusals are returned to the agent as tool results and are never retried on the agent's behalf; the agent must decide whether to re-plan, ask for operator action, or stop.
Headless mode
Start the server, then run the client:
dotnet run --project samples\Robotics\IntentEnabledRobot\IntentEnabledRobot.csproj -- --insecure
'1' | dotnet run --project samples\Robotics\IntentViewerClient\IntentViewerClient.csproj -- --server opc.tcp://localhost:62840/IntentEnabledRobot --insecure
The client lists the target pucks published by the server's OpenUSD bindings. Enter the number for Bin, Fixture, Inspect, or Handoff; the same pick handler used by the viewport reads the mapped LocationType pose and submits a linear move.
Headless keyboard control is disabled when MCP stdio is active because both would read from standard input. Use --mcp --transport http if you want the headless menu and an MCP client at the same time.
--insecure is for localhost demos only. It accepts any server certificate for localhost demos; do not use it for production systems.
Viewport mode
On a machine with the optional viewer assembly and native OpenUSD renderer payload installed next to the sample output, run:
dotnet run --project samples\Robotics\IntentViewerClient\IntentViewerClient.csproj -- --server opc.tcp://localhost:62840/IntentEnabledRobot --insecure --view --fetch-assets .\artifacts\intent-viewer-stage --pick-mode Auto
Click one of the four target pucks at /World/Targets/Bin, /World/Targets/Fixture, /World/Targets/Inspect, or /World/Targets/Handoff. With --pick-mode Auto, the viewer uses renderer-backed pointer picking first: the OpenUSD viewer owns input handling, DPI scaling, physical-pixel conversion, stale-revision retry, and reports hits through the host callback. Misses do not submit intents. If renderer picking is unavailable or unsupported, Auto falls back to the command prim watcher: the host watches /World/IntentCommand and turns a written prim path into an intent. Use --pick-mode Renderer to require renderer-backed picks, or --pick-mode CommandPrim to use only the command-prim path. The renderer payload supports win-x64, linux-x64 and osx-arm64; where it is unavailable the sample falls back to headless mode and says why.
Refusals and cancellation
Refusals are normal method outputs: Accepted=false, an IntentFailureEnum, and a Message. The sample switches on the failure to show retry, re-plan, or escalate decisions. Safety failures such as SafetyStop and SafetyLimitExceeded are reported as safety-system refusals observed by the client, not overridden by it.
Press C while an intent is moving to call CancelIntent(ProcessStop). The output deliberately continues through Cancelling and waits for terminal Cancelled, demonstrating that cancel admission is not the end of robot motion.
Mission demo
Add --mission to submit a tiny two-step mission with a released base and unreleased horizon before interactive picking:
dotnet run --project samples\Robotics\IntentViewerClient\IntentViewerClient.csproj -- --server opc.tcp://localhost:62840/IntentEnabledRobot --insecure --mission
Agent workflow for pallet stacking
Use MCP when an LLM agent should drive the same robot a human watches:
dotnet run --project samples\Robotics\IntentEnabledRobot\IntentEnabledRobot.csproj -- --insecure
dotnet run --project samples\Robotics\IntentViewerClient\IntentViewerClient.csproj --framework net10.0 -- --server opc.tcp://localhost:62840/IntentEnabledRobot --insecure --view --mcp --transport http --port 5100
Configure the MCP client with http://localhost:5100/mcp, or omit --view --transport http and
register the dotnet run ... -- --mcp command as a stdio MCP server.
Before commanding anything, the agent should read:
robotics_list_controllersrobotics_read_controllerforSupportedIntents,SupportedFacets,Bin,Fixture,ParallelGripper,HeldPartPosition,HeldPartVisibleandPayloadSlotNNFilledrobotics_read_stateforReady, mode, safety and command ownerrobotics_request_control
The stacking scenario then exercises the full surface:
- provoke and handle a refusal, for example by submitting while another session owns control and
observing
IntentFailureEnum.ControlNotOwned; - take command authority explicitly;
- submit direct pick, move and place intents;
- call
robotics_wait_operationandrobotics_list_operationswhile motion is active; - pause, resume, cancel and retry as explicit tools, never as hidden client behaviour;
- compile a multi-step mission with
robotics_submit_mission; - inspect
robotics_list_missions, update a mission horizon, and cancel a mission; - verify payload outputs:
HeldPartVisible, changedHeldPartPosition, and filled stack slots.
Agents must not retry a refusal blindly, assume authority, ignore SupportedIntents, or keep issuing
commands after safety/mode/authority refusals without re-reading state and changing the plan.
Transcript-style sequence for one part:
agent -> robotics_list_controllers()
server -> [{ name: "UR5eIntentController", nodeId: "ns=...;s=..." }]
agent -> robotics_read_controller(controllerId)
server -> SupportedIntents includes Pick, Place, LinearMove, JointMove and Wait;
SupportedFacets includes RI-Mission and RI-Mission-Horizon;
locations include Bin and Fixture; outputs include PayloadSlot01Filled.
agent -> robotics_read_state(controllerId)
server -> Ready=true, OperationalMode=AutomaticExternal, ControlOwner=<other session>
agent -> robotics_submit_linear_move(controllerId, { intentId: "probe", target: ... })
server -> { accepted: false, failure: ControlNotOwned, message: "..." }
agent decision: this is an authority refusal, not a motion problem; do not retry the same call.
agent -> robotics_request_control(controllerId)
server -> { granted: true }
agent -> robotics_submit_pick(controllerId,
{ intentId: "pick-slot-01", source: "<Bin NodeId>", tool: "<ParallelGripper NodeId>" })
server -> { accepted: true, operation: "<IntentOperation NodeId>" }
agent -> robotics_wait_operation(controllerId, "pick-slot-01", operation, 2000)
server -> completed=true
agent -> robotics_submit_joint_move(controllerId,
{ intentId: "carry-slot-01", jointTargets: [0.1, -1.0, 1.5, -0.9, 0.75, 0.0] },
axisCount: 6)
server -> { accepted: true, operation: "<IntentOperation NodeId>" }
agent -> robotics_wait_operation(controllerId, "carry-slot-01", operation, 2000)
server -> completed=true; HeldPartPosition has changed and HeldPartVisible is true.
agent -> robotics_submit_place(controllerId,
{ intentId: "place-slot-01", destination: "<Fixture NodeId>", tool: "<ParallelGripper NodeId>" })
server -> { accepted: true, operation: "<IntentOperation NodeId>" }
agent -> robotics_wait_operation(controllerId, "place-slot-01", operation, 2000)
server -> completed=true; PayloadSlot01Filled is true.