-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathProgram.cs
More file actions
302 lines (271 loc) · 14.5 KB
/
Copy pathProgram.cs
File metadata and controls
302 lines (271 loc) · 14.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Configuration;
using Microsoft.Win32.SafeHandles;
using TestStandMCP.Services;
using TestStandMCP.Tools;
namespace TestStandMCP;
internal class Program
{
[DllImport("kernel32.dll")] private static extern bool AllocConsole();
[DllImport("kernel32.dll")] private static extern bool SetConsoleTitle(string title);
[DllImport("kernel32.dll")] private static extern bool SetConsoleOutputCP(uint cp);
[DllImport("kernel32.dll")] private static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);
[DllImport("kernel32.dll")] private static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr CreateFile(string name, uint access, uint share,
IntPtr security, uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr GetStdHandle(int nStdHandle);
private const uint GENERIC_WRITE = 0x40000000;
private const uint FILE_SHARE_WRITE = 2;
private const uint OPEN_EXISTING = 3;
private const int STD_ERROR_HANDLE = -12;
private const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;
static async Task<int> Main(string[] args)
{
// Out-of-process prototype-load worker (spawned by the server itself). Handle it FIRST —
// before any console/banner/DI setup — so it stays silent on stdout except for its single
// result line, and never allocates a console window. It owns its own short-lived engine,
// performs the (possibly process-fatal) native LabVIEW Load Prototype, and hard-exits. See
// LoadPrototypeWorker for the rationale (isolating the 0xC06D007E .lvlibp delay-load crash).
if (args.Length > 0 && args[0] == "--load-prototype-worker")
return await LoadPrototypeWorker.RunAsync(args);
// When launched as an MCP subprocess stdin is redirected — allocate a visible
// console window so Console.Error output (banner + command panel) is visible.
if (Console.IsInputRedirected)
{
AllocConsole();
SetConsoleTitle("TestStand MCP Server");
SetConsoleOutputCP(65001); // UTF-8
// Re-point Console.Error at the new console window (CONOUT$)
var handle = CreateFile("CONOUT$", GENERIC_WRITE, FILE_SHARE_WRITE,
IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero);
if (handle != new IntPtr(-1))
{
// Windows 10 conhost does NOT enable ANSI/VT processing on a freshly
// AllocConsole'd output buffer, so the banner's color + OSC-8 hyperlink
// escapes would be printed literally. (Windows 11 conhost enables it by
// default — hence the banner renders fine on the dev machine but not here.)
EnableVirtualTerminalProcessing(handle);
var stream = new FileStream(new SafeFileHandle(handle, true), FileAccess.Write);
Console.SetError(new StreamWriter(stream, new UTF8Encoding(false)) { AutoFlush = true });
}
}
else
{
// Direct launch (stdin not redirected): the inherited console may also lack VT
// processing on Windows 10 — enable it on the stderr handle the banner uses.
EnableVirtualTerminalProcessing(GetStdHandle(STD_ERROR_HANDLE));
}
// OSC 8 hyperlink: clickable "Zühlke" in terminals that support ANSI hyperlinks
// (Windows Terminal, VS Code terminal, etc.)
const string url = "https://www.zuehlke.com/en/industries/industrial-sector";
const string osc8 = "\x1b]8;;";
const string st = "\x1b\\";
// ANSI: light-violet background (RGB 180 130 220), bright-white foreground, bold
const string bgOn = "\x1b[48;2;180;130;220m\x1b[97m\x1b[1m";
const string bgOff = "\x1b[0m";
Console.Error.WriteLine($"{bgOn} TestStand MCP: developed by {osc8}{url}{st}Zühlke{osc8}{st} Claude {bgOff}");
Console.Error.WriteLine();
// Explicit opt-in maintenance command — NEVER runs on the normal MCP serve path.
// Mirrors .claude\agents\link-agents.bat: junctions %USERPROFILE%\.claude\agents to
// the .claude\agents folder shipped next to this exe, so Claude Code sees the deployed
// agents user-wide. Meaningless under non-Claude hosts (Copilot etc.) that don't read
// ~/.claude/agents — which is exactly why it stays a deliberate manual step.
if (args.Contains("--setup-agents"))
return SetupAgents();
// ── Configuration ─────────────────────────────────────────────────────
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables("TESTSTAND_MCP_")
.AddCommandLine(args)
.Build();
// ── Dependency Injection ──────────────────────────────────────────────
var services = new ServiceCollection();
// Logging: write to stderr so stdout remains clean for MCP JSON-RPC
services.AddLogging(b =>
{
b.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
b.SetMinimumLevel(ParseLogLevel(
config["Logging:LogLevel:Default"] ?? "Information"));
});
services.AddSingleton<IConfiguration>(config);
services.AddSingleton<ITestStandService, TestStandService>();
services.AddSingleton<ISequenceEditorService, SequenceEditorService>();
services.AddSingleton<TestStandToolRegistry>();
services.AddSingleton<TestStandResourceProvider>();
services.AddSingleton<TestStandPromptProvider>();
services.AddSingleton<McpServer>();
var sp = services.BuildServiceProvider();
// ── Station defaults ──────────────────────────────────────────────────
// Read here rather than injecting IConfiguration into the service, so TestStandService stays
// framework-agnostic (and constructible from the tests with nothing but a logger). The
// TestStand ENVIRONMENT belongs in configuration, not in a per-call parameter: it can only be
// chosen before the engine is created, so it is a property of the server process, and MCP
// hosts configure a server exactly once — through args/env in their mcp.json.
// Deliberately NOT reading the older TestStand:AutoConnect / TestStand:EnginePath keys: they
// have never been consumed, and quietly activating them now would change behaviour on any
// station that filled them in expecting them to work.
sp.GetRequiredService<ITestStandService>().ApplyStationDefaults(
environmentPath: config["TestStand:EnvironmentPath"],
environmentAutoDetect: bool.TryParse(config["TestStand:EnvironmentAutoDetect"], out var autoDetect) && autoDetect,
connectTimeoutSeconds: int.TryParse(config["TestStand:ConnectTimeoutSeconds"], out var timeout) ? timeout : 0);
// ── Run ───────────────────────────────────────────────────────────────
var logger = sp.GetRequiredService<ILogger<Program>>();
logger.LogInformation("TestStand MCP Server v1.0.0 starting...");
logger.LogInformation("Platform: {OS}", Environment.OSVersion);
logger.LogInformation("Runtime: {Runtime}", RuntimeInformation.FrameworkDescription);
// Handle --version flag
if (args.Contains("--version"))
{
Console.WriteLine("TestStand MCP Server 1.0.0");
return 0;
}
// Handle --list-tools flag (useful for debugging)
if (args.Contains("--list-tools"))
{
var registry = sp.GetRequiredService<TestStandToolRegistry>();
Console.Error.WriteLine("\nAvailable Tools:");
foreach (var tool in registry.GetTools())
Console.Error.WriteLine($" {tool.Name,-35} {tool.Description}");
Console.Error.WriteLine();
return 0;
}
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
logger.LogInformation("Shutdown signal received.");
cts.Cancel();
};
var server = sp.GetRequiredService<McpServer>();
int exitCode;
try
{
await server.RunAsync(cts.Token);
exitCode = 0;
}
catch (OperationCanceledException)
{
logger.LogInformation("Server shut down cleanly.");
exitCode = 0;
}
catch (Exception ex)
{
logger.LogCritical(ex, "Fatal error");
exitCode = 1;
}
finally
{
// Ensure TestStand engine is released (ShutDown + suppress the last engine's RCW
// finalization — see DisconnectAsync).
var ts = sp.GetService<ITestStandService>();
ts?.Dispose();
}
// Hard-terminate to skip CLR shutdown finalization. After the engine has been ShutDown,
// the lingering TestStand / NI License-Manager (NILM) COM RCWs otherwise crash the .NET 8
// runtime during finalization at process exit (fast-fail 0xC0000409) and can raise a
// Windows Error Reporting dialog. TerminateProcess exits immediately with our exit code,
// before the runtime touches those RCWs. (Mirrors the integration-test ProcessExit guard.)
Console.Out.Flush();
Console.Error.Flush();
TerminateProcess(GetCurrentProcess(), (uint)exitCode);
return exitCode; // not reached
}
/// <summary>
/// Creates a directory junction %USERPROFILE%\.claude\agents → the .claude\agents folder
/// shipped next to this executable, mirroring .claude\agents\link-agents.bat so Claude Code
/// picks up the deployed agents user-wide. Junctions need no admin rights. Aborts (non-zero)
/// if the target already exists as a REAL directory, so a user's own agents are never clobbered.
/// Explicit opt-in only — this is never invoked on the MCP serve path.
/// </summary>
private static int SetupAgents()
{
string src = Path.Combine(AppContext.BaseDirectory, ".claude", "agents")
.TrimEnd(Path.DirectorySeparatorChar);
string link = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".claude", "agents");
Console.Error.WriteLine();
Console.Error.WriteLine($" Junction : {link}");
Console.Error.WriteLine($" Target : {src}");
Console.Error.WriteLine();
if (!Directory.Exists(src))
{
Console.Error.WriteLine($" [ERROR] Agents source folder not found: {src}");
return 1;
}
// Ensure the parent .claude directory exists
Directory.CreateDirectory(Path.GetDirectoryName(link)!);
// Handle an existing target
if (Directory.Exists(link))
{
bool isReparse = (new DirectoryInfo(link).Attributes & FileAttributes.ReparsePoint) != 0;
if (isReparse)
{
Console.Error.WriteLine(" Existing link found - removing and recreating...");
Directory.Delete(link, false); // removes only the junction, not the target contents
}
else
{
Console.Error.WriteLine($" [ABORTED] \"{link}\" already exists as a real directory.");
Console.Error.WriteLine(" Please back up/remove its contents and run again.");
return 1;
}
}
// Create the junction via cmd's mklink /J (needs no admin rights; Directory.CreateSymbolicLink
// would create a symlink requiring privilege / Developer Mode).
var psi = new System.Diagnostics.ProcessStartInfo("cmd.exe", $"/c mklink /J \"{link}\" \"{src}\"")
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true
};
using var proc = System.Diagnostics.Process.Start(psi)!;
proc.StandardOutput.ReadToEnd();
string err = proc.StandardError.ReadToEnd();
proc.WaitForExit();
if (proc.ExitCode != 0)
{
Console.Error.WriteLine($" [ERROR] Failed to create the junction. {err.Trim()}");
return 1;
}
Console.Error.WriteLine(" Done. All projects now use the agents from this folder.");
Console.Error.WriteLine();
return 0;
}
/// <summary>
/// Best-effort: turn on ENABLE_VIRTUAL_TERMINAL_PROCESSING so the console interprets ANSI
/// escape sequences (24-bit color, OSC-8 hyperlinks) instead of printing them as raw text.
/// No-op when the handle is not a real console (e.g. output redirected to a pipe/file).
/// </summary>
private static void EnableVirtualTerminalProcessing(IntPtr handle)
{
if (handle == IntPtr.Zero || handle == new IntPtr(-1)) return;
if (GetConsoleMode(handle, out uint mode))
SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
}
private static LogLevel ParseLogLevel(string level) => level.ToLowerInvariant() switch
{
"trace" => LogLevel.Trace,
"debug" => LogLevel.Debug,
"information" => LogLevel.Information,
"warning" => LogLevel.Warning,
"error" => LogLevel.Error,
"critical" => LogLevel.Critical,
_ => LogLevel.Information
};
}