@minicor/mcp-server 4.7.0 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +18 -7
  2. package/dist/__tests__/config-stores.test.js +8 -7
  3. package/dist/__tests__/config-stores.test.js.map +1 -1
  4. package/dist/__tests__/dev-mode-draft.test.js +9 -10
  5. package/dist/__tests__/dev-mode-draft.test.js.map +1 -1
  6. package/dist/__tests__/discovery.test.js +6 -5
  7. package/dist/__tests__/discovery.test.js.map +1 -1
  8. package/dist/__tests__/helpers.test.js +20 -0
  9. package/dist/__tests__/helpers.test.js.map +1 -1
  10. package/dist/__tests__/http-server.test.js +118 -0
  11. package/dist/__tests__/http-server.test.js.map +1 -1
  12. package/dist/__tests__/mds-errors.test.d.ts +2 -0
  13. package/dist/__tests__/mds-errors.test.d.ts.map +1 -0
  14. package/dist/__tests__/mds-errors.test.js +91 -0
  15. package/dist/__tests__/mds-errors.test.js.map +1 -0
  16. package/dist/__tests__/state.test.js +104 -44
  17. package/dist/__tests__/state.test.js.map +1 -1
  18. package/dist/__tests__/vm-broker.test.d.ts +2 -0
  19. package/dist/__tests__/vm-broker.test.d.ts.map +1 -0
  20. package/dist/__tests__/vm-broker.test.js +812 -0
  21. package/dist/__tests__/vm-broker.test.js.map +1 -0
  22. package/dist/__tests__/vm-management.test.d.ts +2 -0
  23. package/dist/__tests__/vm-management.test.d.ts.map +1 -0
  24. package/dist/__tests__/vm-management.test.js +507 -0
  25. package/dist/__tests__/vm-management.test.js.map +1 -0
  26. package/dist/agent-service-client.d.ts +12 -0
  27. package/dist/agent-service-client.d.ts.map +1 -1
  28. package/dist/agent-service-client.js +74 -0
  29. package/dist/agent-service-client.js.map +1 -1
  30. package/dist/helpers.d.ts +14 -2
  31. package/dist/helpers.d.ts.map +1 -1
  32. package/dist/helpers.js +12 -3
  33. package/dist/helpers.js.map +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +4 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/lds-client.d.ts +59 -10
  38. package/dist/lds-client.d.ts.map +1 -1
  39. package/dist/lds-client.js +53 -9
  40. package/dist/lds-client.js.map +1 -1
  41. package/dist/lib.d.ts +6 -0
  42. package/dist/lib.d.ts.map +1 -1
  43. package/dist/lib.js +5 -1
  44. package/dist/lib.js.map +1 -1
  45. package/dist/mds-errors.d.ts +32 -0
  46. package/dist/mds-errors.d.ts.map +1 -0
  47. package/dist/mds-errors.js +61 -0
  48. package/dist/mds-errors.js.map +1 -0
  49. package/dist/server-surface.d.ts +2 -0
  50. package/dist/server-surface.d.ts.map +1 -1
  51. package/dist/server-surface.js +5 -2
  52. package/dist/server-surface.js.map +1 -1
  53. package/dist/state.d.ts +28 -14
  54. package/dist/state.d.ts.map +1 -1
  55. package/dist/state.js +58 -40
  56. package/dist/state.js.map +1 -1
  57. package/dist/tools/agents.d.ts.map +1 -1
  58. package/dist/tools/agents.js +2 -3
  59. package/dist/tools/agents.js.map +1 -1
  60. package/dist/tools/config-stores.d.ts.map +1 -1
  61. package/dist/tools/config-stores.js +11 -12
  62. package/dist/tools/config-stores.js.map +1 -1
  63. package/dist/tools/context.d.ts.map +1 -1
  64. package/dist/tools/context.js +1 -2
  65. package/dist/tools/context.js.map +1 -1
  66. package/dist/tools/core.d.ts.map +1 -1
  67. package/dist/tools/core.js +2 -3
  68. package/dist/tools/core.js.map +1 -1
  69. package/dist/tools/dev-mode-draft.d.ts.map +1 -1
  70. package/dist/tools/dev-mode-draft.js +16 -17
  71. package/dist/tools/dev-mode-draft.js.map +1 -1
  72. package/dist/tools/discovery.d.ts.map +1 -1
  73. package/dist/tools/discovery.js +7 -8
  74. package/dist/tools/discovery.js.map +1 -1
  75. package/dist/tools/issues.d.ts.map +1 -1
  76. package/dist/tools/issues.js +7 -8
  77. package/dist/tools/issues.js.map +1 -1
  78. package/dist/tools/jobs.d.ts.map +1 -1
  79. package/dist/tools/jobs.js +1 -2
  80. package/dist/tools/jobs.js.map +1 -1
  81. package/dist/tools/session.d.ts.map +1 -1
  82. package/dist/tools/session.js +9 -10
  83. package/dist/tools/session.js.map +1 -1
  84. package/dist/tools/skills.d.ts.map +1 -1
  85. package/dist/tools/skills.js +2 -3
  86. package/dist/tools/skills.js.map +1 -1
  87. package/dist/tools/sync-tools.d.ts.map +1 -1
  88. package/dist/tools/sync-tools.js +2 -3
  89. package/dist/tools/sync-tools.js.map +1 -1
  90. package/dist/tools/tasks.d.ts +3 -0
  91. package/dist/tools/tasks.d.ts.map +1 -0
  92. package/dist/tools/tasks.js +51 -0
  93. package/dist/tools/tasks.js.map +1 -0
  94. package/dist/tools/tfa.d.ts.map +1 -1
  95. package/dist/tools/tfa.js +14 -5
  96. package/dist/tools/tfa.js.map +1 -1
  97. package/dist/tools/vm-rpa.d.ts +2 -1
  98. package/dist/tools/vm-rpa.d.ts.map +1 -1
  99. package/dist/tools/vm-rpa.js +82 -38
  100. package/dist/tools/vm-rpa.js.map +1 -1
  101. package/dist/tools/vm.d.ts +2 -1
  102. package/dist/tools/vm.d.ts.map +1 -1
  103. package/dist/tools/vm.js +511 -114
  104. package/dist/tools/vm.js.map +1 -1
  105. package/dist/tools/workflow-ops.d.ts.map +1 -1
  106. package/dist/tools/workflow-ops.js.map +1 -1
  107. package/dist/types.d.ts +6 -0
  108. package/dist/types.d.ts.map +1 -1
  109. package/dist/vm-service-client.d.ts +8 -2
  110. package/dist/vm-service-client.d.ts.map +1 -1
  111. package/dist/vm-service-client.js.map +1 -1
  112. package/dist/vm-target.d.ts +55 -0
  113. package/dist/vm-target.d.ts.map +1 -0
  114. package/dist/vm-target.js +134 -0
  115. package/dist/vm-target.js.map +1 -0
  116. package/package.json +1 -1
package/dist/tools/vm.js CHANGED
@@ -1,20 +1,54 @@
1
1
  import { z } from "zod";
2
- import { ok, text, json, NOT_CONNECTED_VM } from "../helpers.js";
3
- import { ldsConnection, setLdsConnection, ldsAuth, getCachedVm, getWorkspaceRegion } from "../state.js";
2
+ import { ok, text, json, DIRECT_VM_TOOLS_DISABLED_HOSTED, } from "../helpers.js";
4
3
  import * as lds from "../lds-client.js";
5
4
  import { generateInspectScript } from "../inspect-scripts.js";
5
+ import { fetchVmCredentials, fetchVmCredentialsSoft, resolveVmTarget, vmTargetParams, } from "../vm-target.js";
6
6
  import { registerVmDiscovery } from "./discovery.js";
7
7
  const regionParam = z
8
8
  .enum(["us", "ca"])
9
9
  .optional()
10
10
  .describe("Region (us or ca). Auto-detected from workspace if omitted.");
11
- function requireVM() {
12
- if (!ldsConnection)
13
- return text(NOT_CONNECTED_VM);
14
- return null;
15
- }
16
- export function register(deps) {
17
- const { server } = deps;
11
+ const targetUserParam = z
12
+ .string()
13
+ .optional()
14
+ .describe("Windows account whose session runs this (broker/multi-session VMs only — REQUIRED there, MDS never picks a user). Omit on standalone VMs. vm_list_sessions shows the registered users.");
15
+ /** A robot-user catalog entry as accepted by the VM service (MDSRegisteredUserInput). */
16
+ const mdsRegisteredUserSchema = z.object({
17
+ username: z.string().describe("Local Windows account name"),
18
+ password: z
19
+ .string()
20
+ .optional()
21
+ .describe("Raw RDP password — write-only. The VM service exchanges it for a Secret Manager reference on save; it is never stored and never echoed back. Supply this or secretRef."),
22
+ secretRef: z
23
+ .string()
24
+ .optional()
25
+ .describe("Existing Google Secret Manager reference for the RDP password"),
26
+ resolution: z
27
+ .string()
28
+ .optional()
29
+ .describe("Per-user session resolution override, e.g. '1920x1080'"),
30
+ });
31
+ /** @param directVmTools false on the hosted stateless server — tools stay registered but point to a local install. */
32
+ export function register(deps, directVmTools = true) {
33
+ const { server, state } = deps;
34
+ function requireDirect() {
35
+ if (!directVmTools)
36
+ return text(DIRECT_VM_TOOLS_DISABLED_HOSTED);
37
+ return null;
38
+ }
39
+ async function requireTarget(args) {
40
+ const disabled = requireDirect();
41
+ if (disabled)
42
+ return { ok: false, res: disabled };
43
+ const resolved = await resolveVmTarget(deps, args);
44
+ if (!resolved.ok)
45
+ return { ok: false, res: text(resolved.message) };
46
+ return { ok: true, target: resolved.target };
47
+ }
48
+ function refreshStoredMode(target, mode) {
49
+ if (state.ldsConnection?.url === target.url)
50
+ state.ldsConnection.mode = mode;
51
+ }
18
52
  // ── VM discovery ─────────────────────────────────────────────
19
53
  registerVmDiscovery(deps);
20
54
  // ── VM deployment ──────────────────────────────────────────
@@ -25,11 +59,23 @@ export function register(deps) {
25
59
  region: regionParam,
26
60
  location: z.enum(["us", "canada", "europe"]).default("us").describe("Geography — auto-selects best region by quota"),
27
61
  vmSize: z.string().default("Standard_D2s_v3").describe("Azure VM size"),
28
- vmNamePrefix: z.string().optional().describe("Custom name prefix (max 15 chars, auto-generated if omitted)"),
62
+ vmNamePrefix: z
63
+ .string()
64
+ .describe("VM name prefix — REQUIRED by the VM service (max 15 chars, truncated if longer; a uniqueness suffix is appended)"),
29
65
  enableRecording: z.boolean().default(true),
30
- }, async ({ workspaceId, region, location, vmSize, vmNamePrefix, enableRecording }) => {
66
+ rdpWidth: z.number().optional().describe("Preferred RDP desktop width"),
67
+ rdpHeight: z.number().optional().describe("Preferred RDP desktop height"),
68
+ mdsKeeperEnabled: z
69
+ .boolean()
70
+ .default(true)
71
+ .describe("Whether MDS signs robot users in automatically (multi-session keeper)"),
72
+ mdsRegisteredUsers: z
73
+ .array(mdsRegisteredUserSchema)
74
+ .optional()
75
+ .describe("Robot user catalog to seed at creation (multi-session VMs). Passwords are exchanged for Secret Manager references and never stored."),
76
+ }, async ({ workspaceId, region, location, vmSize, vmNamePrefix, enableRecording, rdpWidth, rdpHeight, mdsKeeperEnabled, mdsRegisteredUsers }) => {
31
77
  try {
32
- const wsRegion = region ?? getWorkspaceRegion(workspaceId);
78
+ const wsRegion = region ?? state.getWorkspaceRegion(workspaceId);
33
79
  const authToken = deps.getAuthToken?.(wsRegion);
34
80
  if (!authToken)
35
81
  return text("No auth token available. Please re-authenticate.");
@@ -44,12 +90,17 @@ export function register(deps) {
44
90
  workspace_id: String(workspaceId),
45
91
  location,
46
92
  vm_size: vmSize,
47
- vm_name_prefix: vmNamePrefix || undefined,
93
+ vm_name_prefix: vmNamePrefix,
48
94
  exposed_ports: [1016, 3389],
49
95
  enable_recording: enableRecording,
50
96
  enable_queue_and_lock: false,
51
- install_laminar_service: false,
52
97
  windows_version: "2022-datacenter",
98
+ ...(rdpWidth != null ? { rdp_width: rdpWidth } : {}),
99
+ ...(rdpHeight != null ? { rdp_height: rdpHeight } : {}),
100
+ mds_keeper_enabled: mdsKeeperEnabled,
101
+ ...(mdsRegisteredUsers?.length
102
+ ? { mds_registered_users: mdsRegisteredUsers }
103
+ : {}),
53
104
  }),
54
105
  });
55
106
  if (!res.ok) {
@@ -57,11 +108,13 @@ export function register(deps) {
57
108
  return text(`VM deployment failed (HTTP ${res.status}): ${errBody}`);
58
109
  }
59
110
  const result = await res.json();
111
+ // Deploy response nests the admin logon under `credentials`.
60
112
  return ok({
61
113
  message: "VM deployment started. It takes ~5-10 minutes.",
62
114
  vm_name: result.vm_name,
63
- username: result.username,
64
- password: result.password,
115
+ status: result.status,
116
+ username: result.credentials?.username,
117
+ password: result.credentials?.password,
65
118
  next_steps: [
66
119
  "Poll vm_list to check when deployment_status becomes 'deployed'",
67
120
  "Once deployed, use install_mds_on_vm to install MDS",
@@ -74,53 +127,113 @@ export function register(deps) {
74
127
  }
75
128
  });
76
129
  // ── Connection management ─────────────────────────────────
77
- server.tool("vm_connect", "Connect to a VM. Provide vmId (from vm_list) to auto-fill credentials, or provide url directly. Must be called before any other vm_* tool.", {
130
+ server.tool("vm_connect", "Connect to a VM. Provide vmId (from vm_list) to auto-fill credentials, or provide url directly. Discovers the MDS mode from /health: on a multi-session (broker) VM, execute/screenshot calls additionally need targetUser. The connection is remembered only by servers that outlive one call (stdio/embedded) — on the hosted server, pass vmId + workspaceId to each vm_* tool instead.", {
78
131
  vmId: z.number().optional().describe("VM ID from vm_list — auto-fills url, apiKey, serviceId"),
132
+ workspaceId: z
133
+ .number()
134
+ .optional()
135
+ .describe("Workspace that owns the VM. With vmId, resolves the VM straight from the VM service — no prior vm_list needed."),
79
136
  url: z.string().optional().describe("MDS base URL (Cloudflare Tunnel) — use this OR vmId"),
80
137
  apiKey: z.string().optional().describe("MDS API key (not needed with vmId)"),
81
138
  serviceId: z.string().optional().describe("MDS service ID (not needed with vmId)"),
82
- }, async ({ vmId, url, apiKey, serviceId }) => {
139
+ region: regionParam,
140
+ }, async ({ vmId, workspaceId, url, apiKey, serviceId, region }) => {
141
+ const disabled = requireDirect();
142
+ if (disabled)
143
+ return disabled;
83
144
  let connectUrl = url;
84
145
  let connectApiKey = apiKey;
85
146
  let connectServiceId = serviceId;
86
147
  let vmName;
87
148
  if (vmId) {
88
- const vm = getCachedVm(vmId);
89
- if (!vm) {
90
- return text("VM not found in cache. Run vm_list first to discover available VMs.");
149
+ const vm = state.getCachedVm(vmId);
150
+ if (vm) {
151
+ if (!vm.tunnel_url) {
152
+ return text(`VM "${vm.vm_name}" has no tunnel URL — it may not be running.`);
153
+ }
154
+ connectUrl = vm.tunnel_url;
155
+ connectApiKey = vm.api_key ?? undefined;
156
+ connectServiceId = vm.service_id ?? undefined;
157
+ vmName = vm.vm_name;
158
+ // Redacted list (newer VM service): resolve the MDS credentials via
159
+ // the per-VM credentials endpoint instead of failing auth later.
160
+ if (!connectApiKey && vm.workspace_id) {
161
+ const wsId = Number(vm.workspace_id);
162
+ const creds = await fetchVmCredentialsSoft(deps, vm.workspace_id, vmId, region ??
163
+ (Number.isNaN(wsId) ? undefined : state.getWorkspaceRegion(wsId)));
164
+ connectApiKey = creds.apiKey;
165
+ connectServiceId = connectServiceId ?? creds.serviceId;
166
+ }
167
+ }
168
+ else if (workspaceId !== undefined) {
169
+ const wsRegion = region ?? state.getWorkspaceRegion(workspaceId);
170
+ const resolved = await fetchVmCredentials(deps, workspaceId, vmId, wsRegion);
171
+ if (!resolved.ok)
172
+ return text(resolved.message);
173
+ if (!resolved.creds.tunnelUrl) {
174
+ return text(`VM ${vmId} has no tunnel URL — MDS may not be installed or running. Check vm_list, or get_install_status if an install is in flight.`);
175
+ }
176
+ connectUrl = resolved.creds.tunnelUrl;
177
+ connectApiKey = resolved.creds.apiKey;
178
+ connectServiceId = resolved.creds.serviceId;
179
+ vmName = resolved.creds.vmName;
91
180
  }
92
- if (!vm.tunnel_url) {
93
- return text(`VM "${vm.vm_name}" has no tunnel URL it may not be running.`);
181
+ else {
182
+ return text("VM not found in cache. Run vm_list first to discover available VMs, or pass workspaceId alongside vmId to resolve it directly.");
94
183
  }
95
- connectUrl = vm.tunnel_url;
96
- connectApiKey = vm.api_key;
97
- connectServiceId = vm.service_id;
98
- vmName = vm.vm_name;
99
184
  }
100
185
  if (!connectUrl) {
101
186
  return text("Provide either vmId (from vm_list) or url to connect.");
102
187
  }
103
188
  try {
104
189
  const health = await lds.health(connectUrl);
105
- setLdsConnection({
190
+ const mode = lds.modeFromHealth(health);
191
+ state.setLdsConnection({
106
192
  url: connectUrl,
107
193
  apiKey: connectApiKey,
108
194
  serviceId: connectServiceId,
109
195
  vmId,
110
196
  vmName,
197
+ mode,
111
198
  });
199
+ const brokerMode = mode === "broker";
200
+ let registeredUsers;
201
+ if (brokerMode) {
202
+ try {
203
+ const status = await lds.status(connectUrl, state.ldsAuth());
204
+ registeredUsers = status.users.map((u) => u.user);
205
+ }
206
+ catch {
207
+ /* surface users lazily via vm_list_sessions instead */
208
+ }
209
+ }
112
210
  return ok({
113
211
  connected: true,
114
212
  url: connectUrl,
115
213
  vmId,
116
214
  vmName,
215
+ mode,
117
216
  authenticated: !!(connectApiKey && connectServiceId),
118
217
  service: health,
218
+ ...(registeredUsers ? { registeredUsers } : {}),
119
219
  next_steps: [
120
- "Take vm_screenshot to see the current desktop state",
121
- "Use vm_inspect_ui mode 'window_list' to see running apps",
122
- "Use the build-rpa-workflow prompt for guided automation building",
123
- "Run Python scripts via vm_execute_script (CDP for browsers, uiautomation for desktop)",
220
+ ...(brokerMode
221
+ ? [
222
+ "This VM is a multi-session broker: EVERY vm_execute_script / vm_screenshot / debug_rpa_step call must include targetUser (MDS never picks a user itself)",
223
+ "Use vm_list_sessions to see registered users and their session/agent state, then ask the user which account to target",
224
+ "Use vm_start_session / vm_stop_session to manage user sessions",
225
+ ]
226
+ : [
227
+ "Take vm_screenshot to see the current desktop state",
228
+ "Use vm_inspect_ui mode 'window_list' to see running apps",
229
+ "Use the build-rpa-workflow prompt for guided automation building",
230
+ "Run Python scripts via vm_execute_script (CDP for browsers, uiautomation for desktop)",
231
+ ]),
232
+ ...(vmId !== undefined
233
+ ? [
234
+ `Pass vmId ${vmId}${workspaceId !== undefined ? ` + workspaceId ${workspaceId}` : " + workspaceId"} on each vm_* call to target this VM without depending on the connection persisting (required on the hosted server)`,
235
+ ]
236
+ : []),
124
237
  ],
125
238
  automation_approaches: {
126
239
  browser: "CDP via Python websocket to Chrome on port 9222 → vm_execute_script",
@@ -134,20 +247,31 @@ export function register(deps) {
134
247
  }
135
248
  });
136
249
  server.tool("vm_disconnect", "Disconnect from the current VM", {}, async () => {
137
- setLdsConnection(null);
250
+ const disabled = requireDirect();
251
+ if (disabled)
252
+ return disabled;
253
+ state.setLdsConnection(null);
138
254
  return text("Disconnected from VM.");
139
255
  });
140
- server.tool("vm_status", "Check health/status of the connected VM", {}, async () => {
141
- const err = requireVM();
142
- if (err)
143
- return err;
256
+ server.tool("vm_status", "Check health/status of a VM, including its MDS mode (standalone or broker)", {
257
+ ...vmTargetParams,
258
+ region: regionParam,
259
+ }, async ({ vmId, workspaceId, region }) => {
260
+ const t = await requireTarget({ vmId, workspaceId, region: region });
261
+ if (!t.ok)
262
+ return t.res;
263
+ const { target } = t;
144
264
  try {
145
- const health = await lds.health(ldsConnection.url);
265
+ const health = await lds.health(target.url);
266
+ const mode = lds.modeFromHealth(health);
267
+ // Keep the stored mode fresh across MDS restarts.
268
+ refreshStoredMode(target, mode);
146
269
  return ok({
147
270
  connected: true,
148
- url: ldsConnection.url,
149
- vmId: ldsConnection.vmId,
150
- vmName: ldsConnection.vmName,
271
+ url: target.url,
272
+ vmId: target.vmId,
273
+ vmName: target.vmName,
274
+ mode,
151
275
  ...health,
152
276
  });
153
277
  }
@@ -156,12 +280,17 @@ export function register(deps) {
156
280
  }
157
281
  });
158
282
  // ── Screenshot ────────────────────────────────────────────
159
- server.tool("vm_screenshot", "Take a full-screen screenshot of the connected VM. Use for verifying actions worked and seeing the current desktop/browser state. For reading small text, prefer vm_screenshot_region.", {}, async () => {
160
- const err = requireVM();
161
- if (err)
162
- return err;
283
+ server.tool("vm_screenshot", "Take a full-screen screenshot of the VM. Use for verifying actions worked and seeing the current desktop/browser state. For reading small text, prefer vm_screenshot_region. On a broker VM, pass targetUser to capture that user's session desktop.", {
284
+ targetUser: targetUserParam,
285
+ ...vmTargetParams,
286
+ region: regionParam,
287
+ }, async ({ targetUser, vmId, workspaceId, region }) => {
288
+ const t = await requireTarget({ vmId, workspaceId, region: region });
289
+ if (!t.ok)
290
+ return t.res;
291
+ const { target } = t;
163
292
  try {
164
- const res = await lds.screenshot(ldsConnection.url, ldsAuth());
293
+ const res = await lds.screenshot(target.url, target.auth, targetUser);
165
294
  const { width, height, size_bytes, capture_duration_ms, timestamp } = res.metadata;
166
295
  return {
167
296
  content: [
@@ -192,10 +321,17 @@ export function register(deps) {
192
321
  y: z.number().describe("Top Y coordinate"),
193
322
  width: z.number().describe("Region width in pixels"),
194
323
  height: z.number().describe("Region height in pixels"),
195
- }, async ({ x, y, width, height }) => {
196
- const err = requireVM();
197
- if (err)
198
- return err;
324
+ targetUser: targetUserParam,
325
+ ...vmTargetParams,
326
+ region: z
327
+ .enum(["us", "ca"])
328
+ .optional()
329
+ .describe("Minicor region (us or ca) the workspace lives in — NOT the screen region. Auto-detected from workspace if omitted."),
330
+ }, async ({ x, y, width, height, targetUser, vmId, workspaceId, region }) => {
331
+ const t = await requireTarget({ vmId, workspaceId, region: region });
332
+ if (!t.ok)
333
+ return t.res;
334
+ const { target } = t;
199
335
  const x2 = x + width;
200
336
  const y2 = y + height;
201
337
  const script = `
@@ -209,7 +345,7 @@ b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
209
345
  print(json.dumps({"image": b64, "width": img.width, "height": img.height}))
210
346
  `.trim();
211
347
  try {
212
- const res = await lds.execute(ldsConnection.url, script, undefined, ldsAuth());
348
+ const res = await lds.execute(target.url, script, { targetUser }, target.auth);
213
349
  if (!res.success) {
214
350
  return text(`Region screenshot failed: ${res.stderr || "unknown error"}`);
215
351
  }
@@ -237,16 +373,20 @@ print(json.dumps({"image": b64, "width": img.width, "height": img.height}))
237
373
  }
238
374
  });
239
375
  // ── Script execution ──────────────────────────────────────
240
- server.tool("vm_execute_script", "Execute a Python script on the VM. This is the primary tool for ALL automation: CDP websocket for browser control, uiautomation/pyautogui for desktop apps, requests/httpx for API calls. Returns stdout, stderr, exit code, and timing.", {
376
+ server.tool("vm_execute_script", "Execute a Python script on the VM. This is the primary tool for ALL automation: CDP websocket for browser control, uiautomation/pyautogui for desktop apps, requests/httpx for API calls. Returns stdout, stderr, exit code, and timing. On a broker VM, targetUser is required — MDS routes the script to that user's session.", {
241
377
  script: z.string().describe("Python script to execute"),
242
378
  executionId: z.string().optional().describe("Execution ID for tracking"),
243
379
  flowId: z.string().optional().describe("Flow ID for tracking"),
244
- }, async ({ script, executionId, flowId }) => {
245
- const err = requireVM();
246
- if (err)
247
- return err;
380
+ targetUser: targetUserParam,
381
+ ...vmTargetParams,
382
+ region: regionParam,
383
+ }, async ({ script, executionId, flowId, targetUser, vmId, workspaceId, region }) => {
384
+ const t = await requireTarget({ vmId, workspaceId, region: region });
385
+ if (!t.ok)
386
+ return t.res;
387
+ const { target } = t;
248
388
  try {
249
- const res = await lds.execute(ldsConnection.url, script, { executionId, flowId }, ldsAuth());
389
+ const res = await lds.execute(target.url, script, { executionId, flowId, targetUser }, target.auth);
250
390
  return ok({
251
391
  success: res.success,
252
392
  exitCode: res.exitCode,
@@ -256,39 +396,241 @@ print(json.dumps({"image": b64, "width": img.width, "height": img.height}))
256
396
  skipped: res.skipped,
257
397
  stopped: res.stopped,
258
398
  resultData: res.resultData,
399
+ // Broker attribution (absent on standalone).
400
+ ...(res.targetUser !== undefined ? { targetUser: res.targetUser } : {}),
259
401
  });
260
402
  }
261
403
  catch (e) {
262
404
  return text(`Execution failed: ${e.message}`);
263
405
  }
264
406
  });
265
- // ── Execution status / control ────────────────────────────
266
- server.tool("vm_execution_status", "Get current execution status on the VM (active script, progress, queue)", {}, async () => {
267
- const err = requireVM();
268
- if (err)
269
- return err;
407
+ // ── Execution status ──────────────────────────────────────
408
+ // vm_execution_control is gone (MDS removed /execute/control); do not re-add.
409
+ server.tool("vm_execution_status", "Get current execution status on the VM (active script, progress, queue). On a broker VM the plain call returns the router-wide status for every user; pass targetUser (and optionally executionId) to read one user's live execution progress instead.", {
410
+ targetUser: targetUserParam,
411
+ executionId: z
412
+ .string()
413
+ .optional()
414
+ .describe("Look up progress for a specific executionId (with or without targetUser)"),
415
+ ...vmTargetParams,
416
+ region: regionParam,
417
+ }, async ({ targetUser, executionId, vmId, workspaceId, region }) => {
418
+ const t = await requireTarget({ vmId, workspaceId, region: region });
419
+ if (!t.ok)
420
+ return t.res;
421
+ const { target } = t;
270
422
  try {
271
- const status = await lds.executionStatus(ldsConnection.url, ldsAuth());
423
+ // A targeted lookup routes to /execute/progress, which brokers
424
+ // forward to that user's agent; the untargeted call stays on
425
+ // /execute/status (router-wide on brokers, slots on standalone).
426
+ if (targetUser || executionId) {
427
+ const progress = await lds.executionProgress(target.url, target.auth, {
428
+ targetUser,
429
+ executionId,
430
+ });
431
+ return ok(progress);
432
+ }
433
+ const status = await lds.executionStatus(target.url, target.auth);
272
434
  return ok(status);
273
435
  }
274
436
  catch (e) {
275
437
  return text(`Failed to get execution status: ${e.message}`);
276
438
  }
277
439
  });
278
- server.tool("vm_execution_control", "Control script execution on the VM (pause, resume, stop, skip)", {
279
- command: z
280
- .enum(["pause", "resume", "stop", "skip"])
281
- .describe("Control command"),
282
- }, async ({ command }) => {
283
- const err = requireVM();
284
- if (err)
285
- return err;
440
+ // ── User sessions (multi-session / broker VMs) ─────────────
441
+ server.tool("vm_list_sessions", "List the registered users on a multi-session (broker) VM with their session and agent state (GET /status). Use this to pick a targetUser for vm_execute_script / vm_screenshot, or to show the user which accounts are available. Requires API-key auth (target by vmId + workspaceId, or connect with vmId or apiKey+serviceId).", {
442
+ ...vmTargetParams,
443
+ region: regionParam,
444
+ }, async ({ vmId, workspaceId, region }) => {
445
+ const t = await requireTarget({ vmId, workspaceId, region: region });
446
+ if (!t.ok)
447
+ return t.res;
448
+ const { target } = t;
449
+ try {
450
+ // /status only exists on brokers; probe mode so a standalone 404 reads clearly.
451
+ const mode = await lds.discoverMode(target.url);
452
+ refreshStoredMode(target, mode);
453
+ if (mode !== "broker") {
454
+ return text(`This VM runs MDS in '${mode}' mode — per-user sessions only exist on multi-session (broker) VMs. Standalone tools work without targetUser.`);
455
+ }
456
+ const status = await lds.status(target.url, target.auth);
457
+ return ok({
458
+ mode,
459
+ broker: status.broker,
460
+ users: status.users,
461
+ queues: status.queues,
462
+ hint: "Pass one of these users as targetUser to vm_execute_script / vm_screenshot / debug_rpa_step. State 'active' means a live agent; 'stopped'/'provisioned' users need vm_start_session (or any job) first.",
463
+ });
464
+ }
465
+ catch (e) {
466
+ return text(`Failed to list sessions: ${e.message}`);
467
+ }
468
+ });
469
+ server.tool("vm_start_session", "Start (or reconnect) a registered user's session on the connected broker VM (POST /sessions/{user}/start). Blocks until the session is up and its agent is registered. Fails with a typed error when the session keeper is disabled (session_start_disabled) or the user is not registered.", {
470
+ user: z.string().describe("Registered Windows account name (see vm_list_sessions)"),
471
+ ...vmTargetParams,
472
+ region: regionParam,
473
+ }, async ({ user, vmId, workspaceId, region }) => {
474
+ const t = await requireTarget({ vmId, workspaceId, region: region });
475
+ if (!t.ok)
476
+ return t.res;
477
+ try {
478
+ const result = await lds.sessionStart(t.target.url, user, t.target.auth);
479
+ return ok({
480
+ ...result,
481
+ hint: `Session for '${user}' is up — target it with targetUser='${user}'.`,
482
+ });
483
+ }
484
+ catch (e) {
485
+ return text(`Failed to start session for '${user}': ${e.message}`);
486
+ }
487
+ });
488
+ server.tool("vm_stop_session", "Disconnect a registered user's session on the connected broker VM (POST /sessions/{user}/stop). The user STAYS logged in but the session goes dark and stays dark until an explicit start or a new job re-arms it; browser jobs keep working, desktop jobs are refused while dark. The keeper-host user cannot be stopped through MDS.", {
489
+ user: z.string().describe("Registered Windows account name (see vm_list_sessions)"),
490
+ ...vmTargetParams,
491
+ region: regionParam,
492
+ }, async ({ user, vmId, workspaceId, region }) => {
493
+ const t = await requireTarget({ vmId, workspaceId, region: region });
494
+ if (!t.ok)
495
+ return t.res;
286
496
  try {
287
- const result = await lds.executionControl(ldsConnection.url, command, ldsAuth());
497
+ const result = await lds.sessionStop(t.target.url, user, t.target.auth);
288
498
  return ok(result);
289
499
  }
290
500
  catch (e) {
291
- return text(`Execution control failed: ${e.message}`);
501
+ return text(`Failed to stop session for '${user}': ${e.message}`);
502
+ }
503
+ });
504
+ // ── MDS configuration & service control (VM service, bearer) ──
505
+ server.tool("vm_get_mds_config", "Read a VM's MDS multi-session configuration and observed state from the VM service: registered robot users (with secretRef), keeper_enabled, session_resolution, live sessions, agent status, and restart/apply tracking. Workspace OWNER/ADMIN only (the service returns 403 otherwise). No VM connection needed — addresses the VM by workspaceId + vmId.", {
506
+ workspaceId: z.number().describe("Workspace ID"),
507
+ vmId: z.number().describe("VM ID from vm_list"),
508
+ region: regionParam,
509
+ }, async ({ workspaceId, vmId, region }) => {
510
+ try {
511
+ const wsRegion = region ?? state.getWorkspaceRegion(workspaceId);
512
+ const authToken = deps.getAuthToken?.(wsRegion);
513
+ if (!authToken)
514
+ return text("No auth token available. Please re-authenticate.");
515
+ const res = await fetch(`${deps.getVmServiceBase()}/workspaces/${workspaceId}/vms/${vmId}/mds-config`, { headers: { "Authorization": `Bearer ${authToken}` } });
516
+ if (!res.ok) {
517
+ const errBody = await res.text().catch(() => "");
518
+ if (res.status === 403) {
519
+ return text(`MDS config is restricted to workspace OWNER/ADMIN (HTTP 403): ${errBody}`);
520
+ }
521
+ return text(`Failed to get MDS config (HTTP ${res.status}): ${errBody}`);
522
+ }
523
+ return ok(await res.json());
524
+ }
525
+ catch (e) {
526
+ return text(`Failed to get MDS config: ${e.message}`);
527
+ }
528
+ });
529
+ server.tool("vm_update_mds_config", "Replace a VM's MDS multi-session configuration via the VM service. registeredUsers is a FULL REPLACE of the robot-user catalog — read vm_get_mds_config first and resend every user you want to keep (existing users keep working when you resend their secretRef unchanged). New or rotated users may carry a raw password: it is exchanged for a Secret Manager reference before saving and never stored or echoed back. Saving ALWAYS queues the MDS restart that applies the config (on broker VMs only the MinicorBroker task restarts — agents and robot sessions keep running); the edit stays 'pending' until the VM next fetches its config. Workspace OWNER/ADMIN only.", {
530
+ workspaceId: z.number().describe("Workspace ID"),
531
+ vmId: z.number().describe("VM ID from vm_list"),
532
+ registeredUsers: z
533
+ .array(mdsRegisteredUserSchema)
534
+ .describe("The complete robot-user catalog (full replace — omitted users are removed). Each entry needs secretRef or a write-only password."),
535
+ keeperEnabled: z
536
+ .boolean()
537
+ .describe("Whether MDS signs robot users in automatically (gates vm_start_session and on-demand logons)"),
538
+ sessionResolution: z
539
+ .string()
540
+ .optional()
541
+ .describe("VM-level session resolution 'WxH' (e.g. '1920x1080'); omit to keep the stored value"),
542
+ region: regionParam,
543
+ }, async ({ workspaceId, vmId, registeredUsers, keeperEnabled, sessionResolution, region }) => {
544
+ try {
545
+ const wsRegion = region ?? state.getWorkspaceRegion(workspaceId);
546
+ const authToken = deps.getAuthToken?.(wsRegion);
547
+ if (!authToken)
548
+ return text("No auth token available. Please re-authenticate.");
549
+ const res = await fetch(`${deps.getVmServiceBase()}/workspaces/${workspaceId}/vms/${vmId}/mds-config`, {
550
+ method: "PUT",
551
+ headers: {
552
+ "Content-Type": "application/json",
553
+ "Authorization": `Bearer ${authToken}`,
554
+ },
555
+ body: JSON.stringify({
556
+ registered_users: registeredUsers,
557
+ keeper_enabled: keeperEnabled,
558
+ ...(sessionResolution !== undefined
559
+ ? { session_resolution: sessionResolution }
560
+ : {}),
561
+ }),
562
+ });
563
+ if (!res.ok) {
564
+ const errBody = await res.text().catch(() => "");
565
+ if (res.status === 403) {
566
+ return text(`MDS config is restricted to workspace OWNER/ADMIN (HTTP 403): ${errBody}`);
567
+ }
568
+ if (res.status === 502) {
569
+ return text(`Password exchange failed — the stored config is untouched (HTTP 502): ${errBody}`);
570
+ }
571
+ return text(`Failed to update MDS config (HTTP ${res.status}): ${errBody}`);
572
+ }
573
+ const config = await res.json();
574
+ return ok({
575
+ ...config,
576
+ hint: "Saved. The MDS restart that applies this config is queued; restart_status/config_applied above track it. Check vm_get_mds_config (or vm_list_sessions once connected) to confirm the VM picked it up.",
577
+ });
578
+ }
579
+ catch (e) {
580
+ return text(`Failed to update MDS config: ${e.message}`);
581
+ }
582
+ });
583
+ server.tool("vm_service_control", "Control the Minicor Desktop Service on a workspace VM through the VM service (no VM connection needed). The service routes on the VM's observed install: on broker/multi-session VMs 'restart' queues a MinicorBroker-only restart applied on the next heartbeat (agents and robot sessions keep running; this also applies pending MDS config) and other actions come back manual_required; legacy standalone installs get the full start/stop/restart/redeploy flows via Azure Run Command.", {
584
+ workspaceId: z.number().describe("Workspace ID"),
585
+ vmId: z.number().describe("VM ID from vm_list"),
586
+ action: z
587
+ .enum(["start", "stop", "restart", "status", "redeploy"])
588
+ .describe("Service action — 'restart' is the only remote action on broker VMs"),
589
+ serviceVersion: z
590
+ .string()
591
+ .optional()
592
+ .describe("Specific MDS version to deploy (redeploy only)"),
593
+ enableRecording: z.boolean().optional().describe("Recording flag (redeploy only)"),
594
+ enableQueueAndLock: z.boolean().optional().describe("Queue-and-lock flag (redeploy only)"),
595
+ region: regionParam,
596
+ }, async ({ workspaceId, vmId, action, serviceVersion, enableRecording, enableQueueAndLock, region }) => {
597
+ try {
598
+ const wsRegion = region ?? state.getWorkspaceRegion(workspaceId);
599
+ const authToken = deps.getAuthToken?.(wsRegion);
600
+ if (!authToken)
601
+ return text("No auth token available. Please re-authenticate.");
602
+ const redeployBody = action === "redeploy" &&
603
+ (serviceVersion !== undefined ||
604
+ enableRecording !== undefined ||
605
+ enableQueueAndLock !== undefined)
606
+ ? {
607
+ ...(serviceVersion !== undefined
608
+ ? { laminar_service_version: serviceVersion }
609
+ : {}),
610
+ ...(enableRecording !== undefined
611
+ ? { enable_recording: enableRecording }
612
+ : {}),
613
+ ...(enableQueueAndLock !== undefined
614
+ ? { enable_queue_and_lock: enableQueueAndLock }
615
+ : {}),
616
+ }
617
+ : undefined;
618
+ const res = await fetch(`${deps.getVmServiceBase()}/workspaces/${workspaceId}/vms/${vmId}/service/${action}`, {
619
+ method: "POST",
620
+ headers: {
621
+ "Content-Type": "application/json",
622
+ "Authorization": `Bearer ${authToken}`,
623
+ },
624
+ ...(redeployBody ? { body: JSON.stringify(redeployBody) } : {}),
625
+ });
626
+ if (!res.ok) {
627
+ const errBody = await res.text().catch(() => "");
628
+ return text(`Service ${action} failed (HTTP ${res.status}): ${errBody}`);
629
+ }
630
+ return ok(await res.json());
631
+ }
632
+ catch (e) {
633
+ return text(`Service ${action} failed: ${e.message}`);
292
634
  }
293
635
  });
294
636
  // ── UI inspection ─────────────────────────────────────────
@@ -316,10 +658,14 @@ print(json.dumps({"image": b64, "width": img.width, "height": img.height}))
316
658
  .number()
317
659
  .optional()
318
660
  .describe("Max tree depth (for element_tree, default 3)"),
319
- }, async ({ mode, x, y, windowTitle, framework, depth }) => {
320
- const err = requireVM();
321
- if (err)
322
- return err;
661
+ targetUser: targetUserParam,
662
+ ...vmTargetParams,
663
+ region: regionParam,
664
+ }, async ({ mode, x, y, windowTitle, framework, depth, targetUser, vmId, workspaceId, region }) => {
665
+ const t = await requireTarget({ vmId, workspaceId, region: region });
666
+ if (!t.ok)
667
+ return t.res;
668
+ const { target } = t;
323
669
  const script = generateInspectScript({
324
670
  mode,
325
671
  x,
@@ -329,7 +675,7 @@ print(json.dumps({"image": b64, "width": img.width, "height": img.height}))
329
675
  depth,
330
676
  });
331
677
  try {
332
- const res = await lds.execute(ldsConnection.url, script, undefined, ldsAuth());
678
+ const res = await lds.execute(target.url, script, { targetUser }, target.auth);
333
679
  if (!res.success) {
334
680
  return text(`UI inspection failed (exit ${res.exitCode}): ${res.stderr || res.stdout}`);
335
681
  }
@@ -345,10 +691,15 @@ print(json.dumps({"image": b64, "width": img.width, "height": img.height}))
345
691
  }
346
692
  });
347
693
  // ── Clipboard ─────────────────────────────────────────────
348
- server.tool("vm_read_clipboard", "Read the current clipboard contents from the VM", {}, async () => {
349
- const err = requireVM();
350
- if (err)
351
- return err;
694
+ server.tool("vm_read_clipboard", "Read the current clipboard contents from the VM", {
695
+ targetUser: targetUserParam,
696
+ ...vmTargetParams,
697
+ region: regionParam,
698
+ }, async ({ targetUser, vmId, workspaceId, region }) => {
699
+ const t = await requireTarget({ vmId, workspaceId, region: region });
700
+ if (!t.ok)
701
+ return t.res;
702
+ const { target } = t;
352
703
  const script = `
353
704
  import json
354
705
 
@@ -370,7 +721,7 @@ except ImportError:
370
721
  print(json.dumps({"clipboard": clipboard_text}))
371
722
  `.trim();
372
723
  try {
373
- const res = await lds.execute(ldsConnection.url, script, undefined, ldsAuth());
724
+ const res = await lds.execute(target.url, script, { targetUser }, target.auth);
374
725
  if (!res.success) {
375
726
  return text(`Clipboard read failed: ${res.stderr || "unknown error"}`);
376
727
  }
@@ -386,28 +737,63 @@ print(json.dumps({"clipboard": clipboard_text}))
386
737
  }
387
738
  });
388
739
  // ── MDS Installation ───────────────────────────────────────
740
+ /**
741
+ * Refuse legacy standalone management actions on broker VMs (live /health
742
+ * probe). Deliberately fails open when MDS is unreachable — being down is
743
+ * the normal reason to (re)install or (re)start.
744
+ */
745
+ async function refuseLegacyStandaloneAction(tunnelUrl, action, consequence) {
746
+ if (!tunnelUrl)
747
+ return null;
748
+ let mode;
749
+ try {
750
+ mode = await lds.discoverMode(tunnelUrl);
751
+ }
752
+ catch {
753
+ return null; // MDS down/unreachable — proceed with the legacy action
754
+ }
755
+ if (mode === "broker") {
756
+ return text(`Refusing ${action}: this VM runs MDS in broker (multi-session) mode, and ${action} is a legacy standalone action — ${consequence} ` +
757
+ `Use the mode-aware restart instead: vm_service_control with action 'restart' restarts only the MinicorBroker scheduled task (agents keep running and re-register), and saving the VM's MDS configuration (vm_update_mds_config) triggers that same restart. ` +
758
+ `See vm_list_sessions for the broker's current users/agents.`);
759
+ }
760
+ return null;
761
+ }
389
762
  const INSTALL_SCRIPT_URL = "https://www.minicor.com/installs/desktop-service/ps1";
390
763
  /** Escape for single-quoted PowerShell strings (only ' needs doubling). */
391
764
  const psEscape = (s) => s.replace(/'/g, "''");
392
765
  server.tool("get_mds_install_command", "Generate a pre-filled PowerShell one-liner to install Minicor Desktop Service on a VM. " +
393
766
  "Returns a command the user can paste into an elevated PowerShell window on the target machine.", {
394
767
  vmId: z.number().optional().describe("VM ID from vm_list — auto-fills serviceId and apiKey"),
768
+ workspaceId: z
769
+ .number()
770
+ .optional()
771
+ .describe("Workspace that owns the VM. With vmId, resolves credentials straight from the VM service — no prior vm_list needed."),
395
772
  serviceId: z.string().optional().describe("MDS service ID (manual override)"),
396
773
  apiKey: z.string().optional().describe("MDS API key (manual override)"),
397
774
  enableRecording: z.boolean().default(true),
398
775
  resolution: z.string().default("1280x720"),
399
776
  installerUrl: z.string().optional().describe("Override installer URL (e.g. ngrok URL for testing)"),
400
- }, async ({ vmId, serviceId, apiKey, enableRecording, resolution, installerUrl }) => {
777
+ region: regionParam,
778
+ }, async ({ vmId, workspaceId, serviceId, apiKey, enableRecording, resolution, installerUrl, region }) => {
401
779
  let sid = serviceId;
402
780
  let key = apiKey;
403
781
  if (vmId) {
404
- const vm = getCachedVm(vmId);
782
+ const vm = state.getCachedVm(vmId);
405
783
  if (vm) {
406
- sid = sid || vm.service_id;
407
- key = key || vm.api_key;
784
+ sid = sid || (vm.service_id ?? undefined);
785
+ key = key || (vm.api_key ?? undefined);
786
+ }
787
+ else if (workspaceId !== undefined) {
788
+ const wsRegion = region ?? state.getWorkspaceRegion(workspaceId);
789
+ const resolved = await fetchVmCredentials(deps, workspaceId, vmId, wsRegion);
790
+ if (!resolved.ok)
791
+ return text(resolved.message);
792
+ sid = sid || resolved.creds.serviceId;
793
+ key = key || resolved.creds.apiKey;
408
794
  }
409
795
  else {
410
- return text("VM not found in cache. Run vm_list first to discover available VMs.");
796
+ return text("VM not found in cache. Run vm_list first to discover available VMs, or pass workspaceId alongside vmId to resolve it directly.");
411
797
  }
412
798
  }
413
799
  const envVars = [];
@@ -441,28 +827,28 @@ print(json.dumps({"clipboard": clipboard_text}))
441
827
  resolution: z.string().default("1280x720"),
442
828
  installerUrl: z.string().optional().describe("Override installer URL (e.g. ngrok URL for testing)"),
443
829
  }, async ({ workspaceId, vmId, region, enableRecording, resolution, installerUrl }, extra) => {
444
- const vm = getCachedVm(vmId);
445
- if (!vm) {
446
- return text("VM not found in cache. Run vm_list first to discover available VMs.");
830
+ const disabled = requireDirect();
831
+ if (disabled)
832
+ return disabled;
833
+ const wsRegion = region ?? state.getWorkspaceRegion(workspaceId);
834
+ const vm = state.getCachedVm(vmId);
835
+ // Resolved up front so the mode probe below still has a tunnel URL.
836
+ const coldCreds = vm
837
+ ? undefined
838
+ : await fetchVmCredentialsSoft(deps, workspaceId, vmId, wsRegion);
839
+ if (coldCreds && !coldCreds.tunnelUrl && !coldCreds.apiKey) {
840
+ return text(`VM ${vmId} not found: it is not in the vm_list cache, and the VM service could not resolve it in workspace ${workspaceId}.`);
447
841
  }
842
+ const refusal = await refuseLegacyStandaloneAction(vm?.tunnel_url ?? coldCreds?.tunnelUrl, "install_mds_on_vm", "the standalone installer replaces the broker/agent scheduled-task topology (a manual /MULTISESSION=1 install is the broker path).");
843
+ if (refusal)
844
+ return refusal;
448
845
  const scriptUrl = installerUrl || INSTALL_SCRIPT_URL;
449
- const key = vm.api_key;
450
- const wsRegion = region ?? getWorkspaceRegion(workspaceId);
451
- // Fetch VM credentials for auto-logon (ensures active desktop session for RPA)
452
- let autoLogonUser = "";
453
- let autoLogonPass = "";
454
- try {
455
- const authToken = deps.getAuthToken?.(wsRegion);
456
- if (authToken) {
457
- const credRes = await fetch(`${deps.getVmServiceBase()}/workspaces/${workspaceId}/vms/${vmId}/credentials`, { headers: { "Authorization": `Bearer ${authToken}` } });
458
- if (credRes.ok) {
459
- const creds = await credRes.json();
460
- autoLogonUser = creds.admin_username || "";
461
- autoLogonPass = creds.admin_password || "";
462
- }
463
- }
464
- }
465
- catch { /* credentials fetch failed, skip auto-logon */ }
846
+ // Fetch VM credentials for auto-logon (ensures active desktop session
847
+ // for RPA) and as the API-key fallback when the list was redacted.
848
+ const creds = coldCreds ?? (await fetchVmCredentialsSoft(deps, workspaceId, vmId, wsRegion));
849
+ const key = vm?.api_key ?? creds.apiKey;
850
+ const autoLogonUser = creds.adminUsername ?? "";
851
+ const autoLogonPass = creds.adminPassword ?? "";
466
852
  // Use single quotes for values that may contain special chars (&, !, }, etc.)
467
853
  const psEscape = (s) => s.replace(/'/g, "''"); // only ' needs escaping in single-quoted PS strings
468
854
  const envVars = [
@@ -560,7 +946,7 @@ print(json.dumps({"clipboard": clipboard_text}))
560
946
  region: regionParam,
561
947
  }, async ({ workspaceId, vmId, region }) => {
562
948
  try {
563
- const wsRegion = region ?? getWorkspaceRegion(workspaceId);
949
+ const wsRegion = region ?? state.getWorkspaceRegion(workspaceId);
564
950
  const vmServiceUrl = deps.getVmServiceBase();
565
951
  const authToken = deps.getAuthToken?.(wsRegion);
566
952
  if (!authToken)
@@ -631,16 +1017,27 @@ print(json.dumps({"clipboard": clipboard_text}))
631
1017
  // ── Start MDS after install ────────────────────────────────
632
1018
  server.tool("start_mds_on_vm", "Start Minicor Desktop Service on an Azure VM via Azure Run Command. " +
633
1019
  "Use after install_mds_on_vm when MDS installed but health check failed (Session 0 issue). " +
634
- "Creates a one-shot scheduled task to launch MDS in the interactive desktop session.", {
1020
+ "Creates a one-shot scheduled task to launch MDS in the interactive desktop session. " +
1021
+ "STANDALONE VMs ONLY — refused when the VM's live /health.mode is broker (use the mode-aware restart there).", {
635
1022
  workspaceId: z.number().describe("Workspace ID"),
636
1023
  vmId: z.number().describe("VM ID from vm_list"),
637
1024
  region: regionParam,
638
1025
  }, async ({ workspaceId, vmId, region }) => {
639
- const vm = getCachedVm(vmId);
640
- if (!vm) {
641
- return text("VM not found in cache. Run vm_list first.");
1026
+ const disabled = requireDirect();
1027
+ if (disabled)
1028
+ return disabled;
1029
+ const wsRegion = region ?? state.getWorkspaceRegion(workspaceId);
1030
+ const vm = state.getCachedVm(vmId);
1031
+ // Resolved up front so the mode probe below still has a tunnel URL.
1032
+ const coldCreds = vm
1033
+ ? undefined
1034
+ : await fetchVmCredentialsSoft(deps, workspaceId, vmId, wsRegion);
1035
+ if (coldCreds && !coldCreds.tunnelUrl && !coldCreds.apiKey) {
1036
+ return text(`VM ${vmId} not found: it is not in the vm_list cache, and the VM service could not resolve it in workspace ${workspaceId}.`);
642
1037
  }
643
- const wsRegion = region ?? getWorkspaceRegion(workspaceId);
1038
+ const refusal = await refuseLegacyStandaloneAction(vm?.tunnel_url ?? coldCreds?.tunnelUrl, "start_mds_on_vm", "it force-kills the running MDS process and relaunches the standalone entry point, which would take down the broker and its agents mid-flight.");
1039
+ if (refusal)
1040
+ return refusal;
644
1041
  // PowerShell script that starts MDS in the interactive session
645
1042
  // Uses WMI to find the logged-on user (more reliable than `query session`)
646
1043
  const startScript = `