Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_VERSION }}

- name: Setup MSBuild
uses: microsoft/setup-msbuild@v1.3

- name: Build native bootstrapper
run: |
msbuild src/WpfVisualTreeMcp.Bootstrapper/WpfVisualTreeMcp.Bootstrapper.vcxproj /m /p:Configuration=Release /p:Platform=x64
msbuild src/WpfVisualTreeMcp.Bootstrapper/WpfVisualTreeMcp.Bootstrapper.vcxproj /m /p:Configuration=Release /p:Platform=Win32

- name: Restore dependencies
run: dotnet restore WpfVisualTreeMcp.sln

Expand All @@ -43,6 +51,65 @@ jobs:
- name: Publish MCP Server
run: dotnet publish src/WpfVisualTreeMcp.Server/WpfVisualTreeMcp.Server.csproj --no-build --configuration Release --output ./publish

- name: Verify auto-injection payload
shell: pwsh
run: |
$netFxInspectorDlls = Get-ChildItem ./src/WpfVisualTreeMcp.Inspector/bin/Release/net48/*.dll
$requiredPayloadFiles = @(
'./publish/native/x64/WpfInspectorBootstrapper.dll'
'./publish/native/x86/WpfInspectorBootstrapper.dll'
'./publish/native/x64/coreclr/WpfVisualTreeMcp.Inspector.runtimeconfig.json'
'./publish/native/x86/coreclr/WpfVisualTreeMcp.Inspector.runtimeconfig.json'
foreach ($architecture in @('x64', 'x86')) {
foreach ($dll in $netFxInspectorDlls) {
"./publish/native/$architecture/$($dll.Name)"
}
}
)
$missingPayloadFiles = @($requiredPayloadFiles | Where-Object { -not (Test-Path -LiteralPath $_) })
if ($missingPayloadFiles.Count -gt 0) {
throw "Missing auto-injection payload files: $($missingPayloadFiles -join ', ')"
}

- name: Pack NuGet package
run: dotnet pack src/WpfVisualTreeMcp.Server/WpfVisualTreeMcp.Server.csproj --no-build --configuration Release --output ./artifacts

- name: Verify NuGet auto-injection payload
shell: pwsh
run: |
$package = Get-ChildItem ./artifacts/*.nupkg | Select-Object -First 1
if ($null -eq $package) { throw 'NuGet package was not created.' }

$netFxInspectorDllNames = Get-ChildItem ./src/WpfVisualTreeMcp.Inspector/bin/Release/net48/*.dll |
Select-Object -ExpandProperty Name
$requiredEntrySuffixes = @(
foreach ($architecture in @('x64', 'x86')) {
"/native/$architecture/WpfInspectorBootstrapper.dll"
"/native/$architecture/coreclr/WpfVisualTreeMcp.Inspector.runtimeconfig.json"
foreach ($dllName in $netFxInspectorDllNames) {
"/native/$architecture/$dllName"
}
}
)

Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName)
try {
$entryNames = @($archive.Entries.FullName)
$missingEntrySuffixes = @($requiredEntrySuffixes | Where-Object {
$requiredSuffix = $_
-not ($entryNames | Where-Object {
$_.EndsWith($requiredSuffix, [StringComparison]::OrdinalIgnoreCase)
})
})
if ($missingEntrySuffixes.Count -gt 0) {
throw "NuGet package is missing auto-injection payload entries: $($missingEntrySuffixes -join ', ')"
}
}
finally {
$archive.Dispose()
}

- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
Expand Down
61 changes: 61 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ jobs:
- name: Setup MSBuild
uses: microsoft/setup-msbuild@v1.3

- name: Build native bootstrapper
run: |
msbuild src/WpfVisualTreeMcp.Bootstrapper/WpfVisualTreeMcp.Bootstrapper.vcxproj /m /p:Configuration=Release /p:Platform=x64
msbuild src/WpfVisualTreeMcp.Bootstrapper/WpfVisualTreeMcp.Bootstrapper.vcxproj /m /p:Configuration=Release /p:Platform=Win32

- name: Restore dependencies
run: dotnet restore WpfVisualTreeMcp.sln

Expand All @@ -44,6 +49,26 @@ jobs:
- name: Publish MCP Server
run: dotnet publish src/WpfVisualTreeMcp.Server/WpfVisualTreeMcp.Server.csproj --no-build --configuration Release --output ./publish/server

- name: Verify auto-injection payload
shell: pwsh
run: |
$netFxInspectorDlls = Get-ChildItem ./src/WpfVisualTreeMcp.Inspector/bin/Release/net48/*.dll
$requiredPayloadFiles = @(
'./publish/server/native/x64/WpfInspectorBootstrapper.dll'
'./publish/server/native/x86/WpfInspectorBootstrapper.dll'
'./publish/server/native/x64/coreclr/WpfVisualTreeMcp.Inspector.runtimeconfig.json'
'./publish/server/native/x86/coreclr/WpfVisualTreeMcp.Inspector.runtimeconfig.json'
foreach ($architecture in @('x64', 'x86')) {
foreach ($dll in $netFxInspectorDlls) {
"./publish/server/native/$architecture/$($dll.Name)"
}
}
)
$missingPayloadFiles = @($requiredPayloadFiles | Where-Object { -not (Test-Path -LiteralPath $_) })
if ($missingPayloadFiles.Count -gt 0) {
throw "Missing auto-injection payload files: $($missingPayloadFiles -join ', ')"
}

- name: Publish Inspector DLL
run: dotnet publish src/WpfVisualTreeMcp.Inspector/WpfVisualTreeMcp.Inspector.csproj --configuration Release --framework net8.0-windows --output ./publish/inspector

Expand All @@ -54,6 +79,42 @@ jobs:
- name: Pack NuGet package
run: dotnet pack src/WpfVisualTreeMcp.Server/WpfVisualTreeMcp.Server.csproj --configuration Release --output ./artifacts

- name: Verify NuGet auto-injection payload
shell: pwsh
run: |
$package = Get-ChildItem ./artifacts/*.nupkg | Select-Object -First 1
if ($null -eq $package) { throw 'NuGet package was not created.' }

$netFxInspectorDllNames = Get-ChildItem ./src/WpfVisualTreeMcp.Inspector/bin/Release/net48/*.dll |
Select-Object -ExpandProperty Name
$requiredEntrySuffixes = @(
foreach ($architecture in @('x64', 'x86')) {
"/native/$architecture/WpfInspectorBootstrapper.dll"
"/native/$architecture/coreclr/WpfVisualTreeMcp.Inspector.runtimeconfig.json"
foreach ($dllName in $netFxInspectorDllNames) {
"/native/$architecture/$dllName"
}
}
)

Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName)
try {
$entryNames = @($archive.Entries.FullName)
$missingEntrySuffixes = @($requiredEntrySuffixes | Where-Object {
$requiredSuffix = $_
-not ($entryNames | Where-Object {
$_.EndsWith($requiredSuffix, [StringComparison]::OrdinalIgnoreCase)
})
})
if ($missingEntrySuffixes.Count -gt 0) {
throw "NuGet package is missing auto-injection payload entries: $($missingEntrySuffixes -join ', ')"
}
}
finally {
$archive.Dispose()
}

# Trusted publishing (OIDC): requires a policy at nuget.org/account/trustedpublishing
# for repo faze79/WPFVisualTreeMcp, workflow release.yml, package WpfVisualTreeMcp,
# plus a repo secret NUGET_USER = the nuget.org profile name. Skipped with a notice
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- Build and package the x64 and x86 native bootstrappers so Auto-injection works from the
NuGet tool and release archive.
- Package the complete .NET Framework Inspector dependency closure and resolve its
co-located private assemblies without relying on the target application's binding redirects.
- Use the standard CoreCLR runtimeconfig filename so it remains accessible from the NuGet
tool's deeply nested installation directory.

## [0.12.0] - 2026-07-24

### Added
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@ public partial class App : Application
```

This enables the MCP server to connect to your application via named pipes for real-time inspection.
The Inspector multi-targets .NET Framework 4.7.2, .NET Framework 4.8, and
.NET 8 for Windows, so project references select a compatible build.

## Usage Examples

Expand Down Expand Up @@ -469,7 +471,7 @@ WpfVisualTreeMcp/
│ │ ├── WpfTools.cs # 20 WPF tools (17 inspection + click/set-text/send-keys)
│ │ ├── Cli/CliRunner.cs # One-shot CLI front-end (v0.4.0)
│ │ └── Services/ # Process & IPC management
│ ├── WpfVisualTreeMcp.Inspector/ # Injected DLL (.NET Framework 4.8)
│ ├── WpfVisualTreeMcp.Inspector/ # Injected DLL (.NET Framework 4.7.2/4.8 and .NET 8)
│ ├── WpfVisualTreeMcp.Injector/ # Managed injection logic (CreateRemoteThread; net48 + net8.0)
Comment on lines +474 to 475

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already corrected in consolidated PR #22: the root README describes WpfVisualTreeMcp.Injector as .NET 8 only. #22 supersedes this stacked PR.

│ ├── WpfVisualTreeMcp.InjectorHelper/# x86 .NET 8 helper exe for cross-arch injection (v0.6.0)
│ ├── WpfVisualTreeMcp.Bootstrapper/ # Native C++ DLL for CLR hosting
Expand Down
7 changes: 7 additions & 0 deletions WpfVisualTreeMcp.sln
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
mcp.json = mcp.json
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WpfVisualTreeMcp.Shared.Tests", "tests\WpfVisualTreeMcp.Shared.Tests\WpfVisualTreeMcp.Shared.Tests.csproj", "{17056590-7FA9-414F-9772-6C60C56C1A5D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -65,6 +67,10 @@ Global
{F6A7B8C9-D0E1-2345-F012-456789012345}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F012-456789012345}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F6A7B8C9-D0E1-2345-F012-456789012345}.Release|Any CPU.Build.0 = Release|Any CPU
{17056590-7FA9-414F-9772-6C60C56C1A5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{17056590-7FA9-414F-9772-6C60C56C1A5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17056590-7FA9-414F-9772-6C60C56C1A5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{17056590-7FA9-414F-9772-6C60C56C1A5D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -77,6 +83,7 @@ Global
{B0A1C2D3-E4F5-6789-0123-456789ABCDEF} = {1A2B3C4D-5E6F-7890-1234-567890ABCDEF}
{E5F6A7B8-C9D0-1234-EF01-345678901234} = {2B3C4D5E-6F78-9012-3456-7890ABCDEF01}
{F6A7B8C9-D0E1-2345-F012-456789012345} = {3C4D5E6F-7890-1234-5678-90ABCDEF0123}
{17056590-7FA9-414F-9772-6C60C56C1A5D} = {3C4D5E6F-7890-1234-5678-90ABCDEF0123}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {12345678-90AB-CDEF-1234-567890ABCDEF}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<!--
Multi-targeted so the x86 InjectorHelper (net8.0) can reference us without
a cross-TFM warning. .NET Framework 4.8 stays around so the existing server
build path is unchanged.
-->
<TargetFrameworks>net48;net8.0</TargetFrameworks>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>WpfVisualTreeMcp.Injector</RootNamespace>
<AssemblyName>WpfVisualTreeMcp.Injector</AssemblyName>
Expand Down
40 changes: 40 additions & 0 deletions src/WpfVisualTreeMcp.Inspector/InspectorService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text.Json;
using System.Threading.Tasks;
using System.Windows;
Expand Down Expand Up @@ -28,6 +29,21 @@ public class InspectorService : IDisposable
private bool _disposed;

private static readonly object _initLock = new();
#if NET48

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in consolidated PR #22 (commit 35c1177): the resolver, its fields, and System.Reflection import now use NETFRAMEWORK, so both net472 and net48 receive the co-located dependency resolver.

private static readonly HashSet<string> _privateDependencyNames = new(StringComparer.OrdinalIgnoreCase)
{
"Microsoft.Bcl.AsyncInterfaces",
"System.Buffers",
"System.Memory",
"System.Numerics.Vectors",
"System.Runtime.CompilerServices.Unsafe",
"System.Text.Encodings.Web",
"System.Text.Json",
"System.Threading.Tasks.Extensions",
"System.ValueTuple",
};
private static bool _dependencyResolverRegistered;
#endif
public static InspectorService? Instance { get; private set; }

/// <summary>
Expand Down Expand Up @@ -105,6 +121,9 @@ public static void Initialize(int processId)
try
{
DebugLog($"Inspector.Initialize called for PID={processId}");
#if NET48
RegisterDependencyResolver();
#endif
Instance = new InspectorService(processId);
DebugLog("Inspector instance created, calling Start()");
Instance.Start();
Expand All @@ -119,6 +138,27 @@ public static void Initialize(int processId)
}
}

#if NET48
private static void RegisterDependencyResolver()
{
if (_dependencyResolverRegistered) return;

var inspectorDirectory = Path.GetDirectoryName(typeof(InspectorService).Assembly.Location);
if (string.IsNullOrEmpty(inspectorDirectory)) return;

AppDomain.CurrentDomain.AssemblyResolve += (_, args) =>
{
var assemblyName = new AssemblyName(args.Name).Name;
if (string.IsNullOrEmpty(assemblyName) || !_privateDependencyNames.Contains(assemblyName))
return null;

var assemblyPath = Path.Combine(inspectorDirectory, assemblyName + ".dll");
return File.Exists(assemblyPath) ? Assembly.LoadFrom(assemblyPath) : null;
};
_dependencyResolverRegistered = true;
}
#endif

private InspectorService(int processId)
{
_treeWalker = new TreeWalker();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net48;net8.0-windows</TargetFrameworks>
<TargetFrameworks>net472;net48;net8.0-windows</TargetFrameworks>
<OutputType>Library</OutputType>
<RootNamespace>WpfVisualTreeMcp.Inspector</RootNamespace>
<AssemblyName>WpfVisualTreeMcp.Inspector</AssemblyName>
Expand All @@ -10,9 +10,9 @@
<UseWPF Condition="'$(TargetFramework)' == 'net8.0-windows'">true</UseWPF>
</PropertyGroup>

<!-- .NET Framework 4.8: explicit WPF references -->
<!-- .NET Framework 4.7.2/4.8: explicit WPF references -->
<!-- (net8.0-windows pulls these in automatically via UseWPF) -->
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
<ItemGroup Condition="'$(TargetFramework)' == 'net472' Or '$(TargetFramework)' == 'net48'">
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
<Reference Include="WindowsBase" />
Expand Down
Loading