@mokoconsulting/mcp-windows 3.0.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 (184) hide show
  1. package/.gitattributes +94 -0
  2. package/.gitmessage +9 -0
  3. package/.mokogitea/ISSUE_TEMPLATE/adr.md +110 -0
  4. package/.mokogitea/ISSUE_TEMPLATE/bug_report.md +48 -0
  5. package/.mokogitea/ISSUE_TEMPLATE/config.yml +18 -0
  6. package/.mokogitea/ISSUE_TEMPLATE/documentation.md +52 -0
  7. package/.mokogitea/ISSUE_TEMPLATE/feature_request.md +51 -0
  8. package/.mokogitea/ISSUE_TEMPLATE/mcp_api_integration.md +48 -0
  9. package/.mokogitea/ISSUE_TEMPLATE/mcp_connection_issue.md +67 -0
  10. package/.mokogitea/ISSUE_TEMPLATE/mcp_tool_request.md +49 -0
  11. package/.mokogitea/ISSUE_TEMPLATE/question.md +82 -0
  12. package/.mokogitea/ISSUE_TEMPLATE/rfc.md +126 -0
  13. package/.mokogitea/ISSUE_TEMPLATE/security.md +51 -0
  14. package/.mokogitea/ISSUE_TEMPLATE/version.md +24 -0
  15. package/.mokogitea/branch-protection.yml +251 -0
  16. package/.mokogitea/workflows/auto-assign.yml +76 -0
  17. package/.mokogitea/workflows/auto-bump.yml +66 -0
  18. package/.mokogitea/workflows/auto-dev-issue.yml +207 -0
  19. package/.mokogitea/workflows/auto-release.yml +421 -0
  20. package/.mokogitea/workflows/branch-cleanup.yml +48 -0
  21. package/.mokogitea/workflows/cascade-dev.yml +10 -0
  22. package/.mokogitea/workflows/changelog-validation.yml +101 -0
  23. package/.mokogitea/workflows/ci-generic.yml +191 -0
  24. package/.mokogitea/workflows/cleanup.yml +87 -0
  25. package/.mokogitea/workflows/codeql-analysis.yml +115 -0
  26. package/.mokogitea/workflows/copilot-agent.yml +44 -0
  27. package/.mokogitea/workflows/deploy-manual.yml +126 -0
  28. package/.mokogitea/workflows/enterprise-firewall-setup.yml +758 -0
  29. package/.mokogitea/workflows/gitleaks.yml +92 -0
  30. package/.mokogitea/workflows/issue-branch.yml +73 -0
  31. package/.mokogitea/workflows/mcp-auto-release.yml +278 -0
  32. package/.mokogitea/workflows/mcp-build-test.yml +65 -0
  33. package/.mokogitea/workflows/mcp-sdk-check.yml +109 -0
  34. package/.mokogitea/workflows/mcp-tool-inventory.yml +61 -0
  35. package/.mokogitea/workflows/notify.yml +70 -0
  36. package/.mokogitea/workflows/npm-publish.yml +113 -0
  37. package/.mokogitea/workflows/pr-check.yml +534 -0
  38. package/.mokogitea/workflows/pre-release.yml +252 -0
  39. package/.mokogitea/workflows/rc-revert.yml +66 -0
  40. package/.mokogitea/workflows/repo-health.yml +712 -0
  41. package/.mokogitea/workflows/repository-cleanup.yml +525 -0
  42. package/.mokogitea/workflows/security-audit.yml +82 -0
  43. package/.mokogitea/workflows/standards-compliance.yml +2614 -0
  44. package/.mokogitea/workflows/sync-version-on-merge.yml +133 -0
  45. package/.mokogitea/workflows/update-server.yml +312 -0
  46. package/.mokogitea/workflows/workflow-sync-trigger.yml +73 -0
  47. package/CHANGELOG.md +130 -0
  48. package/CLAUDE.md +49 -0
  49. package/CONTRIBUTING.md +161 -0
  50. package/ISSUES.md +601 -0
  51. package/Makefile +70 -0
  52. package/README.md +80 -0
  53. package/automation/ci-issue-reporter.sh +237 -0
  54. package/config.example.json +18 -0
  55. package/dist/index.d.ts +3 -0
  56. package/dist/index.js +111 -0
  57. package/dist/shell.d.ts +50 -0
  58. package/dist/shell.js +209 -0
  59. package/dist/tools/apps.d.ts +3 -0
  60. package/dist/tools/apps.js +63 -0
  61. package/dist/tools/audio.d.ts +3 -0
  62. package/dist/tools/audio.js +142 -0
  63. package/dist/tools/audio_apps.d.ts +3 -0
  64. package/dist/tools/audio_apps.js +86 -0
  65. package/dist/tools/automation.d.ts +3 -0
  66. package/dist/tools/automation.js +261 -0
  67. package/dist/tools/bluetooth.d.ts +3 -0
  68. package/dist/tools/bluetooth.js +96 -0
  69. package/dist/tools/clipboard.d.ts +3 -0
  70. package/dist/tools/clipboard.js +118 -0
  71. package/dist/tools/config.d.ts +3 -0
  72. package/dist/tools/config.js +85 -0
  73. package/dist/tools/dialog.d.ts +3 -0
  74. package/dist/tools/dialog.js +72 -0
  75. package/dist/tools/display.d.ts +3 -0
  76. package/dist/tools/display.js +256 -0
  77. package/dist/tools/drives.d.ts +3 -0
  78. package/dist/tools/drives.js +98 -0
  79. package/dist/tools/environment.d.ts +3 -0
  80. package/dist/tools/environment.js +129 -0
  81. package/dist/tools/execute.d.ts +3 -0
  82. package/dist/tools/execute.js +28 -0
  83. package/dist/tools/filesystem.d.ts +3 -0
  84. package/dist/tools/filesystem.js +230 -0
  85. package/dist/tools/firewall.d.ts +3 -0
  86. package/dist/tools/firewall.js +108 -0
  87. package/dist/tools/hosts.d.ts +3 -0
  88. package/dist/tools/hosts.js +119 -0
  89. package/dist/tools/maintenance.d.ts +3 -0
  90. package/dist/tools/maintenance.js +236 -0
  91. package/dist/tools/netstat.d.ts +3 -0
  92. package/dist/tools/netstat.js +56 -0
  93. package/dist/tools/network.d.ts +3 -0
  94. package/dist/tools/network.js +70 -0
  95. package/dist/tools/notification.d.ts +3 -0
  96. package/dist/tools/notification.js +41 -0
  97. package/dist/tools/power.d.ts +3 -0
  98. package/dist/tools/power.js +104 -0
  99. package/dist/tools/printer.d.ts +3 -0
  100. package/dist/tools/printer.js +97 -0
  101. package/dist/tools/process.d.ts +3 -0
  102. package/dist/tools/process.js +54 -0
  103. package/dist/tools/process_kill.d.ts +3 -0
  104. package/dist/tools/process_kill.js +48 -0
  105. package/dist/tools/recycle_bin.d.ts +3 -0
  106. package/dist/tools/recycle_bin.js +108 -0
  107. package/dist/tools/registry.d.ts +3 -0
  108. package/dist/tools/registry.js +136 -0
  109. package/dist/tools/scheduler.d.ts +3 -0
  110. package/dist/tools/scheduler.js +116 -0
  111. package/dist/tools/service.d.ts +3 -0
  112. package/dist/tools/service.js +79 -0
  113. package/dist/tools/startup.d.ts +3 -0
  114. package/dist/tools/startup.js +159 -0
  115. package/dist/tools/storage.d.ts +3 -0
  116. package/dist/tools/storage.js +129 -0
  117. package/dist/tools/system.d.ts +3 -0
  118. package/dist/tools/system.js +84 -0
  119. package/dist/tools/system_mgmt.d.ts +3 -0
  120. package/dist/tools/system_mgmt.js +174 -0
  121. package/dist/tools/terminal.d.ts +3 -0
  122. package/dist/tools/terminal.js +80 -0
  123. package/dist/tools/theme.d.ts +3 -0
  124. package/dist/tools/theme.js +165 -0
  125. package/dist/tools/usb.d.ts +3 -0
  126. package/dist/tools/usb.js +52 -0
  127. package/dist/tools/virtual_desktop.d.ts +3 -0
  128. package/dist/tools/virtual_desktop.js +112 -0
  129. package/dist/tools/wifi.d.ts +3 -0
  130. package/dist/tools/wifi.js +136 -0
  131. package/dist/tools/window.d.ts +3 -0
  132. package/dist/tools/window.js +189 -0
  133. package/dist/tools/winget.d.ts +3 -0
  134. package/dist/tools/winget.js +79 -0
  135. package/dist/tools/wsl.d.ts +3 -0
  136. package/dist/tools/wsl.js +99 -0
  137. package/docs/API.md +63 -0
  138. package/docs/ARCHITECTURE.md +73 -0
  139. package/docs/INSTALLATION.md +102 -0
  140. package/docs/index.md +12 -0
  141. package/package.json +35 -0
  142. package/scripts/setup.mjs +123 -0
  143. package/src/index.ts +125 -0
  144. package/src/shell.ts +253 -0
  145. package/src/tools/apps.ts +76 -0
  146. package/src/tools/audio.ts +161 -0
  147. package/src/tools/audio_apps.ts +98 -0
  148. package/src/tools/automation.ts +297 -0
  149. package/src/tools/bluetooth.ts +114 -0
  150. package/src/tools/clipboard.ts +138 -0
  151. package/src/tools/config.ts +105 -0
  152. package/src/tools/dialog.ts +87 -0
  153. package/src/tools/display.ts +285 -0
  154. package/src/tools/drives.ts +124 -0
  155. package/src/tools/environment.ts +146 -0
  156. package/src/tools/execute.ts +35 -0
  157. package/src/tools/filesystem.ts +273 -0
  158. package/src/tools/firewall.ts +125 -0
  159. package/src/tools/hosts.ts +135 -0
  160. package/src/tools/maintenance.ts +299 -0
  161. package/src/tools/netstat.ts +72 -0
  162. package/src/tools/network.ts +84 -0
  163. package/src/tools/notification.ts +50 -0
  164. package/src/tools/power.ts +123 -0
  165. package/src/tools/printer.ts +114 -0
  166. package/src/tools/process.ts +80 -0
  167. package/src/tools/process_kill.ts +57 -0
  168. package/src/tools/recycle_bin.ts +126 -0
  169. package/src/tools/registry.ts +165 -0
  170. package/src/tools/scheduler.ts +140 -0
  171. package/src/tools/service.ts +102 -0
  172. package/src/tools/startup.ts +180 -0
  173. package/src/tools/storage.ts +141 -0
  174. package/src/tools/system.ts +99 -0
  175. package/src/tools/system_mgmt.ts +190 -0
  176. package/src/tools/terminal.ts +117 -0
  177. package/src/tools/theme.ts +205 -0
  178. package/src/tools/usb.ts +65 -0
  179. package/src/tools/virtual_desktop.ts +122 -0
  180. package/src/tools/wifi.ts +157 -0
  181. package/src/tools/window.ts +211 -0
  182. package/src/tools/winget.ts +100 -0
  183. package/src/tools/wsl.ts +112 -0
  184. package/tsconfig.json +19 -0
@@ -0,0 +1,161 @@
1
+ /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ *
4
+ * Tools: windows_audio_get (#6), windows_audio_set (#7)
5
+ * Uses compiled SetMute.exe for reliable COM audio control.
6
+ */
7
+
8
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { z } from 'zod';
10
+ import { runPowerShell } from '../shell.js';
11
+
12
+ const AUDIO_PS = `
13
+ Add-Type -TypeDefinition @'
14
+ using System;
15
+ using System.Runtime.InteropServices;
16
+
17
+ public class WinAudio {
18
+ [Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
19
+ interface IMMDeviceEnumerator {
20
+ int EnumAudioEndpoints(int dataFlow, int dwStateMask, out IntPtr ppDevices);
21
+ int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice ppEndpoint);
22
+ }
23
+
24
+ [Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
25
+ interface IMMDevice {
26
+ int Activate(ref Guid iid, int dwClsCtx, IntPtr pActivationParams, [MarshalAs(UnmanagedType.IUnknown)] out object ppInterface);
27
+ }
28
+
29
+ [Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
30
+ interface IAudioEndpointVolume {
31
+ int RegisterControlChangeNotify(IntPtr pNotify);
32
+ int UnregisterControlChangeNotify(IntPtr pNotify);
33
+ int GetChannelCount(out int pnChannelCount);
34
+ int SetMasterVolumeLevel(float fLevelDB, ref Guid pguidEventContext);
35
+ int SetMasterVolumeLevelScalar(float fLevel, ref Guid pguidEventContext);
36
+ int GetMasterVolumeLevel(out float pfLevelDB);
37
+ int GetMasterVolumeLevelScalar(out float pfLevel);
38
+ int SetChannelVolumeLevel(int nChannel, float fLevelDB, ref Guid pguidEventContext);
39
+ int SetChannelVolumeLevelScalar(int nChannel, float fLevel, ref Guid pguidEventContext);
40
+ int GetChannelVolumeLevel(int nChannel, out float pfLevelDB);
41
+ int GetChannelVolumeLevelScalar(int nChannel, out float pfLevel);
42
+ int SetMute([MarshalAs(UnmanagedType.Bool)] bool bMute, ref Guid pguidEventContext);
43
+ int GetMute([MarshalAs(UnmanagedType.Bool)] out bool pbMute);
44
+ }
45
+
46
+ private static IAudioEndpointVolume GetVolume() {
47
+ var type = Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"));
48
+ var enumerator = (IMMDeviceEnumerator)Activator.CreateInstance(type);
49
+ IMMDevice device;
50
+ enumerator.GetDefaultAudioEndpoint(0, 1, out device);
51
+ var iid = new Guid("5CDF2C82-841E-4546-9722-0CF74078229A");
52
+ object obj;
53
+ device.Activate(ref iid, 0x17, IntPtr.Zero, out obj);
54
+ return (IAudioEndpointVolume)obj;
55
+ }
56
+
57
+ public static float GetVolumeLevel() {
58
+ var vol = GetVolume();
59
+ float level;
60
+ vol.GetMasterVolumeLevelScalar(out level);
61
+ return level;
62
+ }
63
+
64
+ public static bool GetMute() {
65
+ var vol = GetVolume();
66
+ bool muted;
67
+ vol.GetMute(out muted);
68
+ return muted;
69
+ }
70
+
71
+ public static void SetVolumeLevel(float level) {
72
+ var vol = GetVolume();
73
+ var ctx = Guid.Empty;
74
+ vol.SetMasterVolumeLevelScalar(level, ref ctx);
75
+ }
76
+
77
+ public static void SetMute(bool mute) {
78
+ var vol = GetVolume();
79
+ var ctx = Guid.Empty;
80
+ vol.SetMute(mute, ref ctx);
81
+ }
82
+ }
83
+ '@ -ErrorAction Stop
84
+ `;
85
+
86
+ export function registerAudioTools(server: McpServer): void {
87
+ server.tool(
88
+ 'windows_audio_get',
89
+ 'Get current audio state: volume level (0-100), mute status, and default playback device.',
90
+ {},
91
+ async () => {
92
+ const ps = `
93
+ ${AUDIO_PS}
94
+ $volume = [math]::Round([WinAudio]::GetVolumeLevel() * 100)
95
+ $muted = [WinAudio]::GetMute()
96
+ $device = (Get-CimInstance Win32_SoundDevice | Where-Object { $_.Status -eq 'OK' } | Select-Object -First 1).Name
97
+ [PSCustomObject]@{
98
+ volume = $volume
99
+ muted = $muted
100
+ device = $device
101
+ } | ConvertTo-Json -Compress`;
102
+
103
+ const result = await runPowerShell(ps, { timeout: 60000 });
104
+ if (result.exitCode !== 0) {
105
+ return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
106
+ }
107
+
108
+ const state = JSON.parse(result.stdout);
109
+ const muteIcon = state.muted ? '🔇' : (state.volume > 50 ? '🔊' : '🔉');
110
+ return {
111
+ content: [{
112
+ type: 'text',
113
+ text: `${muteIcon} Volume: ${state.volume}%${state.muted ? ' (MUTED)' : ''}\nDevice: ${state.device || 'Unknown'}`,
114
+ }],
115
+ };
116
+ },
117
+ );
118
+
119
+ server.tool(
120
+ 'windows_audio_set',
121
+ 'Set audio volume (0-100), mute/unmute, or toggle mute.',
122
+ {
123
+ volume: z.number().min(0).max(100).optional().describe('Volume level 0-100'),
124
+ mute: z.enum(['true', 'false', 'toggle']).optional().describe('Mute state: true, false, or toggle'),
125
+ },
126
+ async ({ volume, mute }) => {
127
+ const commands: string[] = [AUDIO_PS];
128
+
129
+ if (volume !== undefined) {
130
+ commands.push(`[WinAudio]::SetVolumeLevel(${volume / 100})`);
131
+ }
132
+
133
+ if (mute === 'toggle') {
134
+ commands.push(`[WinAudio]::SetMute(-not [WinAudio]::GetMute())`);
135
+ } else if (mute === 'true') {
136
+ commands.push(`[WinAudio]::SetMute($true)`);
137
+ } else if (mute === 'false') {
138
+ commands.push(`[WinAudio]::SetMute($false)`);
139
+ }
140
+
141
+ // Read back state
142
+ commands.push(`
143
+ $vol = [math]::Round([WinAudio]::GetVolumeLevel() * 100)
144
+ $m = [WinAudio]::GetMute()
145
+ [PSCustomObject]@{ volume = $vol; muted = $m } | ConvertTo-Json -Compress`);
146
+
147
+ const result = await runPowerShell(commands.join('\n'));
148
+ if (result.exitCode !== 0) {
149
+ return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
150
+ }
151
+
152
+ const state = JSON.parse(result.stdout);
153
+ return {
154
+ content: [{
155
+ type: 'text',
156
+ text: `Volume set to ${state.volume}%${state.muted ? ' (MUTED)' : ''}`,
157
+ }],
158
+ };
159
+ },
160
+ );
161
+ }
@@ -0,0 +1,98 @@
1
+ /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ *
4
+ * Tool: windows_audio_app_volumes (#8)
5
+ */
6
+
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
+ import { z } from 'zod';
9
+ import { runPowerShell } from '../shell.js';
10
+
11
+ export function registerAudioAppTools(server: McpServer): void {
12
+ server.tool(
13
+ 'windows_audio_app_volumes',
14
+ 'Get or set per-application audio volume levels. Without set params, lists all app audio sessions.',
15
+ {
16
+ app: z.string().optional().describe('App name to target (for set operations)'),
17
+ volume: z.number().min(0).max(100).optional().describe('Volume to set (0-100)'),
18
+ mute: z.enum(['true', 'false', 'toggle']).optional().describe('Mute state to set'),
19
+ },
20
+ async ({ app, volume, mute }) => {
21
+ // List mode — show all audio sessions via PowerShell + COM
22
+ const ps = `
23
+ Add-Type -TypeDefinition @'
24
+ using System;
25
+ using System.Runtime.InteropServices;
26
+ using System.Collections.Generic;
27
+ using System.Diagnostics;
28
+
29
+ public class AudioSessions {
30
+ [Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
31
+ interface IMMDeviceEnumerator {
32
+ int EnumAudioEndpoints(int dataFlow, int dwStateMask, out IntPtr ppDevices);
33
+ int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice ppEndpoint);
34
+ }
35
+
36
+ [Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
37
+ interface IMMDevice {
38
+ int Activate(ref Guid iid, int dwClsCtx, IntPtr pActivationParams, [MarshalAs(UnmanagedType.IUnknown)] out object ppInterface);
39
+ }
40
+
41
+ [Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
42
+ interface IAudioSessionManager2 {
43
+ int _0(); // QueryInterface stuff
44
+ int _1();
45
+ int GetSessionEnumerator(out IAudioSessionEnumerator ppEnum);
46
+ }
47
+
48
+ [Guid("E2F5BB11-0570-40CA-ACDD-3AA01277DEE8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
49
+ interface IAudioSessionEnumerator {
50
+ int GetCount(out int count);
51
+ int GetSession(int index, [MarshalAs(UnmanagedType.IUnknown)] out object ppSession);
52
+ }
53
+
54
+ public static string GetSessions() {
55
+ // For now, use a simpler approach via Get-Process
56
+ return "use_powershell";
57
+ }
58
+ }
59
+ '@ -ErrorAction SilentlyContinue
60
+
61
+ # Use the Volume Mixer approach via Get-Process with audio
62
+ $sessions = Get-Process | Where-Object { $_.MainWindowTitle -ne '' -or $_.ProcessName -match 'chrome|firefox|spotify|vlc|teams|discord|zoom|music|video|media' } |
63
+ Select-Object Id, ProcessName, MainWindowTitle |
64
+ Sort-Object ProcessName -Unique
65
+
66
+ $sessions | ForEach-Object {
67
+ [PSCustomObject]@{
68
+ PID = $_.Id
69
+ Name = $_.ProcessName
70
+ Title = $_.MainWindowTitle
71
+ }
72
+ } | ConvertTo-Json -Depth 3 -Compress`;
73
+
74
+ const result = await runPowerShell(ps, { timeout: 10000 });
75
+ if (result.exitCode !== 0) {
76
+ return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
77
+ }
78
+
79
+ if (!app && volume === undefined && !mute) {
80
+ // List mode
81
+ if (!result.stdout) {
82
+ return { content: [{ type: 'text', text: 'No audio app sessions detected.' }] };
83
+ }
84
+ const apps = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)];
85
+ const lines = apps.map((a: { PID: number; Name: string; Title: string }) =>
86
+ `PID ${String(a.PID).padStart(6)} ${a.Name.padEnd(25)} ${a.Title || '(no window)'}`,
87
+ );
88
+ return {
89
+ content: [{ type: 'text', text: `Audio-capable processes:\n\n${lines.join('\n')}\n\nNote: Per-app volume control requires the SndVol COM API. Use windows_audio_set for master volume.` }],
90
+ };
91
+ }
92
+
93
+ return {
94
+ content: [{ type: 'text', text: `Per-app volume set/mute requires elevated SndVol COM access. Use windows_execute with PowerShell to control specific app audio, or use windows_audio_set for master volume.` }],
95
+ };
96
+ },
97
+ );
98
+ }
@@ -0,0 +1,297 @@
1
+ /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ *
4
+ * Tools: windows_shortcut (#78), windows_input (#79),
5
+ * windows_font_list (#80), windows_sandbox (#81), windows_screen_capture (#82)
6
+ */
7
+
8
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { z } from 'zod';
10
+ import { runPowerShell } from '../shell.js';
11
+
12
+ export function registerAutomationTools(server: McpServer): void {
13
+ server.tool(
14
+ 'windows_shortcut',
15
+ 'Create, read, or modify Windows shortcut (.lnk) files.',
16
+ {
17
+ action: z.enum(['create', 'read']).describe('Action'),
18
+ path: z.string().describe('Shortcut .lnk file path'),
19
+ target: z.string().optional().describe('Target path (for create)'),
20
+ arguments: z.string().optional().describe('Arguments (for create)'),
21
+ icon: z.string().optional().describe('Icon path (for create)'),
22
+ working_dir: z.string().optional().describe('Working directory (for create)'),
23
+ description: z.string().optional().describe('Description (for create)'),
24
+ },
25
+ async ({ action, path, target, arguments: args, icon, working_dir, description }) => {
26
+ if (action === 'read') {
27
+ const ps = `
28
+ $ws = New-Object -ComObject WScript.Shell
29
+ $sc = $ws.CreateShortcut('${path.replace(/'/g, "''")}')
30
+ [PSCustomObject]@{
31
+ Target = $sc.TargetPath
32
+ Arguments = $sc.Arguments
33
+ WorkingDir = $sc.WorkingDirectory
34
+ Icon = $sc.IconLocation
35
+ Description = $sc.Description
36
+ Hotkey = $sc.Hotkey
37
+ WindowStyle = $sc.WindowStyle
38
+ } | ConvertTo-Json -Compress`;
39
+ const result = await runPowerShell(ps, { timeout: 10000 });
40
+ if (result.exitCode !== 0) return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
41
+ const sc = JSON.parse(result.stdout);
42
+ return {
43
+ content: [{
44
+ type: 'text',
45
+ text: `${path}\n Target: ${sc.Target}\n Args: ${sc.Arguments || '(none)'}\n Dir: ${sc.WorkingDir || '(none)'}\n Icon: ${sc.Icon || '(default)'}\n Description: ${sc.Description || '(none)'}`,
46
+ }],
47
+ };
48
+ }
49
+
50
+ if (!target) return { content: [{ type: 'text', text: 'create requires target.' }], isError: true };
51
+ const ps = `
52
+ $ws = New-Object -ComObject WScript.Shell
53
+ $sc = $ws.CreateShortcut('${path.replace(/'/g, "''")}')
54
+ $sc.TargetPath = '${target.replace(/'/g, "''")}'
55
+ ${args ? `$sc.Arguments = '${args.replace(/'/g, "''")}'` : ''}
56
+ ${working_dir ? `$sc.WorkingDirectory = '${working_dir.replace(/'/g, "''")}'` : ''}
57
+ ${icon ? `$sc.IconLocation = '${icon.replace(/'/g, "''")}'` : ''}
58
+ ${description ? `$sc.Description = '${description.replace(/'/g, "''")}'` : ''}
59
+ $sc.Save()
60
+ "Shortcut created: ${path}"`;
61
+ const result = await runPowerShell(ps, { timeout: 10000 });
62
+ return { content: [{ type: 'text', text: result.stdout || result.stderr }], isError: result.exitCode !== 0 };
63
+ },
64
+ );
65
+
66
+ server.tool(
67
+ 'windows_input',
68
+ 'Simulate keyboard presses, key combos, or type text. Also simulate mouse clicks and movement.',
69
+ {
70
+ type: z.enum(['key', 'combo', 'text', 'mouse_click', 'mouse_move']).describe('Input type'),
71
+ key: z.string().optional().describe('Key name for key/combo (e.g. "Enter", "Ctrl+C", "Alt+F4")'),
72
+ text: z.string().optional().describe('Text to type (for text type)'),
73
+ x: z.number().optional().describe('Mouse X coordinate'),
74
+ y: z.number().optional().describe('Mouse Y coordinate'),
75
+ button: z.enum(['left', 'right', 'middle']).default('left').describe('Mouse button'),
76
+ delay: z.number().default(50).describe('Delay between actions in ms'),
77
+ },
78
+ async ({ type, key, text, x, y, button, delay }) => {
79
+ let ps: string;
80
+
81
+ switch (type) {
82
+ case 'key':
83
+ if (!key) return { content: [{ type: 'text', text: 'key requires key name.' }], isError: true };
84
+ ps = `
85
+ Add-Type -AssemblyName System.Windows.Forms
86
+ [System.Windows.Forms.SendKeys]::SendWait('{${key.toUpperCase()}}')
87
+ "Sent key: ${key}"`;
88
+ break;
89
+
90
+ case 'combo':
91
+ if (!key) return { content: [{ type: 'text', text: 'combo requires key.' }], isError: true };
92
+ // Map Ctrl+C style to SendKeys format: ^c
93
+ const mapped = key
94
+ .replace(/Ctrl\+/gi, '^')
95
+ .replace(/Alt\+/gi, '%')
96
+ .replace(/Shift\+/gi, '+')
97
+ .replace(/Win\+/gi, '^{ESC}'); // approximate
98
+ ps = `
99
+ Add-Type -AssemblyName System.Windows.Forms
100
+ [System.Windows.Forms.SendKeys]::SendWait('${mapped}')
101
+ "Sent combo: ${key}"`;
102
+ break;
103
+
104
+ case 'text':
105
+ if (!text) return { content: [{ type: 'text', text: 'text requires text.' }], isError: true };
106
+ // Escape SendKeys special chars
107
+ const escaped = text.replace(/[+^%~(){}[\]]/g, '{$&}');
108
+ ps = `
109
+ Add-Type -AssemblyName System.Windows.Forms
110
+ [System.Windows.Forms.SendKeys]::SendWait('${escaped.replace(/'/g, "''")}')
111
+ "Typed ${text.length} characters"`;
112
+ break;
113
+
114
+ case 'mouse_click':
115
+ if (x === undefined || y === undefined) return { content: [{ type: 'text', text: 'mouse_click requires x and y.' }], isError: true };
116
+ const btnFlag = button === 'right' ? '0x0008,0x0010' : button === 'middle' ? '0x0020,0x0040' : '0x0002,0x0004';
117
+ ps = `
118
+ Add-Type @'
119
+ using System.Runtime.InteropServices;
120
+ public class MouseInput {
121
+ [DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
122
+ [DllImport("user32.dll")] public static extern void mouse_event(uint dwFlags, int dx, int dy, uint dwData, int dwExtraInfo);
123
+ }
124
+ '@
125
+ [MouseInput]::SetCursorPos(${x}, ${y})
126
+ Start-Sleep -Milliseconds ${delay}
127
+ [MouseInput]::mouse_event(${btnFlag.split(',')[0]}, 0, 0, 0, 0)
128
+ [MouseInput]::mouse_event(${btnFlag.split(',')[1]}, 0, 0, 0, 0)
129
+ "Clicked ${button} at (${x}, ${y})"`;
130
+ break;
131
+
132
+ case 'mouse_move':
133
+ if (x === undefined || y === undefined) return { content: [{ type: 'text', text: 'mouse_move requires x and y.' }], isError: true };
134
+ ps = `
135
+ Add-Type @'
136
+ using System.Runtime.InteropServices;
137
+ public class MouseMove { [DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y); }
138
+ '@
139
+ [MouseMove]::SetCursorPos(${x}, ${y})
140
+ "Moved mouse to (${x}, ${y})"`;
141
+ break;
142
+ }
143
+
144
+ const result = await runPowerShell(ps, { timeout: 10000 });
145
+ return { content: [{ type: 'text', text: result.stdout || result.stderr }], isError: result.exitCode !== 0 };
146
+ },
147
+ );
148
+
149
+ server.tool(
150
+ 'windows_font_list',
151
+ 'List installed system and user fonts.',
152
+ {
153
+ filter: z.string().optional().describe('Filter by font name'),
154
+ },
155
+ async ({ filter }) => {
156
+ const filterClause = filter ? `| Where-Object { $_.Name -like '*${filter.replace(/'/g, "''")}*' }` : '';
157
+ const ps = `
158
+ Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Fonts' |
159
+ ForEach-Object { $_.PSObject.Properties } |
160
+ Where-Object { $_.Name -notlike 'PS*' } |
161
+ ForEach-Object { [PSCustomObject]@{ Name = $_.Name; File = $_.Value } } ${filterClause} |
162
+ Sort-Object Name |
163
+ ConvertTo-Json -Depth 3 -Compress`;
164
+
165
+ const result = await runPowerShell(ps, { timeout: 15000 });
166
+ if (result.exitCode !== 0) return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
167
+ if (!result.stdout) return { content: [{ type: 'text', text: 'No fonts found.' }] };
168
+
169
+ const fonts = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)];
170
+ const lines = fonts.map((f: { Name: string }) => ` ${f.Name}`);
171
+ return { content: [{ type: 'text', text: `Installed fonts (${fonts.length}):\n${lines.join('\n')}` }] };
172
+ },
173
+ );
174
+
175
+ server.tool(
176
+ 'windows_sandbox',
177
+ 'Check Windows Sandbox status, launch with default or custom config.',
178
+ {
179
+ action: z.enum(['status', 'launch', 'generate_config']).default('status').describe('Action'),
180
+ mapped_folder: z.string().optional().describe('Host folder to map into sandbox'),
181
+ read_only: z.boolean().default(true).describe('Map folder as read-only'),
182
+ networking: z.boolean().default(true).describe('Enable networking in sandbox'),
183
+ startup_command: z.string().optional().describe('Command to run on sandbox startup'),
184
+ config_path: z.string().optional().describe('Path to save/load .wsb config'),
185
+ },
186
+ async ({ action, mapped_folder, read_only, networking, startup_command, config_path }) => {
187
+ if (action === 'status') {
188
+ const ps = `
189
+ $feature = Get-WindowsOptionalFeature -Online -FeatureName 'Containers-DisposableClientVM' -ErrorAction SilentlyContinue
190
+ if ($feature) {
191
+ "Windows Sandbox: $($feature.State)"
192
+ } else {
193
+ "Windows Sandbox feature not found"
194
+ }`;
195
+ const result = await runPowerShell(ps, { timeout: 15000 });
196
+ return { content: [{ type: 'text', text: result.stdout || result.stderr }] };
197
+ }
198
+
199
+ if (action === 'generate_config' || action === 'launch') {
200
+ const wsbLines = ['<Configuration>'];
201
+ if (!networking) wsbLines.push(' <Networking>Disable</Networking>');
202
+ if (mapped_folder) {
203
+ wsbLines.push(' <MappedFolders>');
204
+ wsbLines.push(' <MappedFolder>');
205
+ wsbLines.push(` <HostFolder>${mapped_folder}</HostFolder>`);
206
+ wsbLines.push(` <ReadOnly>${read_only ? 'true' : 'false'}</ReadOnly>`);
207
+ wsbLines.push(' </MappedFolder>');
208
+ wsbLines.push(' </MappedFolders>');
209
+ }
210
+ if (startup_command) {
211
+ wsbLines.push(` <LogonCommand><Command>${startup_command}</Command></LogonCommand>`);
212
+ }
213
+ wsbLines.push('</Configuration>');
214
+ const wsb = wsbLines.join('\n');
215
+
216
+ if (action === 'generate_config') {
217
+ if (config_path) {
218
+ const ps = `Set-Content -Path '${config_path.replace(/'/g, "''")}' -Value @'\n${wsb}\n'@\n"Config saved: ${config_path}"`;
219
+ const result = await runPowerShell(ps, { timeout: 5000 });
220
+ return { content: [{ type: 'text', text: result.stdout || result.stderr }] };
221
+ }
222
+ return { content: [{ type: 'text', text: wsb }] };
223
+ }
224
+
225
+ // Launch
226
+ const tempWsb = config_path || `$env:TEMP\\mcp_sandbox_${Date.now()}.wsb`;
227
+ const ps = `
228
+ Set-Content -Path '${tempWsb}' -Value @'
229
+ ${wsb}
230
+ '@
231
+ Start-Process '${tempWsb}'
232
+ "Sandbox launched${config_path ? '' : ' (temp config)'}"`;
233
+ const result = await runPowerShell(ps, { timeout: 10000 });
234
+ return { content: [{ type: 'text', text: result.stdout || result.stderr }] };
235
+ }
236
+
237
+ return { content: [{ type: 'text', text: 'Unknown action.' }], isError: true };
238
+ },
239
+ );
240
+
241
+ server.tool(
242
+ 'windows_screen_capture',
243
+ 'Start or stop screen recording. Uses ffmpeg if available, otherwise reports status only.',
244
+ {
245
+ action: z.enum(['start', 'stop', 'status']).describe('Action'),
246
+ output: z.string().optional().describe('Output file path (for start)'),
247
+ fps: z.number().default(15).describe('Framerate'),
248
+ },
249
+ async ({ action, output, fps }) => {
250
+ if (action === 'status') {
251
+ const ps = `
252
+ $ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
253
+ $recording = Get-Process ffmpeg -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -like '*gdigrab*' }
254
+ [PSCustomObject]@{
255
+ FfmpegInstalled = $null -ne $ffmpeg
256
+ FfmpegPath = if ($ffmpeg) { $ffmpeg.Source } else { $null }
257
+ ActiveRecording = $null -ne $recording
258
+ RecordingPID = if ($recording) { $recording.Id } else { $null }
259
+ } | ConvertTo-Json -Compress`;
260
+ const result = await runPowerShell(ps, { timeout: 10000 });
261
+ if (result.exitCode !== 0) return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
262
+ const info = JSON.parse(result.stdout);
263
+ return {
264
+ content: [{
265
+ type: 'text',
266
+ text: `ffmpeg: ${info.FfmpegInstalled ? info.FfmpegPath : 'Not installed (install via winget: winget install Gyan.FFmpeg)'}\nRecording: ${info.ActiveRecording ? `Active (PID ${info.RecordingPID})` : 'None'}`,
267
+ }],
268
+ };
269
+ }
270
+
271
+ if (action === 'start') {
272
+ const outPath = output || `A:/temp/recording_${Date.now()}.mp4`;
273
+ const ps = `
274
+ $ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
275
+ if (-not $ffmpeg) { throw "ffmpeg not installed. Install with: winget install Gyan.FFmpeg" }
276
+ Start-Process ffmpeg -ArgumentList '-f gdigrab -framerate ${fps} -i desktop -c:v libx264 -preset ultrafast "${outPath}"' -WindowStyle Hidden -PassThru | ForEach-Object { "Recording started (PID $($_.Id)). Output: ${outPath}" }`;
277
+ const result = await runPowerShell(ps, { timeout: 10000 });
278
+ return { content: [{ type: 'text', text: result.stdout || result.stderr }], isError: result.exitCode !== 0 };
279
+ }
280
+
281
+ if (action === 'stop') {
282
+ const ps = `
283
+ $procs = Get-Process ffmpeg -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -like '*gdigrab*' -or $_.CommandLine -eq $null }
284
+ if ($procs) {
285
+ $procs | ForEach-Object { $_.CloseMainWindow() | Out-Null }
286
+ Start-Sleep -Seconds 2
287
+ $procs | Stop-Process -Force -ErrorAction SilentlyContinue
288
+ "Recording stopped ($(@($procs).Count) process(es))"
289
+ } else { "No active recording found" }`;
290
+ const result = await runPowerShell(ps, { timeout: 10000 });
291
+ return { content: [{ type: 'text', text: result.stdout || result.stderr }] };
292
+ }
293
+
294
+ return { content: [{ type: 'text', text: 'Unknown action.' }], isError: true };
295
+ },
296
+ );
297
+ }
@@ -0,0 +1,114 @@
1
+ /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ *
4
+ * Tools: windows_bluetooth_get (#45), windows_bluetooth_control (#46)
5
+ */
6
+
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
+ import { z } from 'zod';
9
+ import { runPowerShell } from '../shell.js';
10
+
11
+ export function registerBluetoothTools(server: McpServer): void {
12
+ server.tool(
13
+ 'windows_bluetooth_get',
14
+ 'Get Bluetooth adapter status and list paired/connected devices.',
15
+ {},
16
+ async () => {
17
+ const ps = `
18
+ $adapter = Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | Where-Object { $_.FriendlyName -match 'Bluetooth' -and $_.Class -eq 'Bluetooth' } | Select-Object -First 1
19
+ $enabled = if ($adapter) { $adapter.Status -eq 'OK' } else { $false }
20
+
21
+ $devices = Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
22
+ Where-Object { $_.FriendlyName -and $_.Class -eq 'Bluetooth' -and $_.FriendlyName -notmatch 'Bluetooth|Radio|Enumerator' } |
23
+ ForEach-Object {
24
+ [PSCustomObject]@{
25
+ Name = $_.FriendlyName
26
+ Status = $_.Status
27
+ InstanceId = $_.InstanceId
28
+ Connected = $_.Status -eq 'OK'
29
+ }
30
+ }
31
+
32
+ [PSCustomObject]@{
33
+ AdapterPresent = $null -ne $adapter
34
+ AdapterName = if ($adapter) { $adapter.FriendlyName } else { 'None' }
35
+ Enabled = $enabled
36
+ Devices = $devices
37
+ } | ConvertTo-Json -Depth 4 -Compress`;
38
+
39
+ const result = await runPowerShell(ps, { timeout: 15000 });
40
+ if (result.exitCode !== 0) {
41
+ return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
42
+ }
43
+
44
+ const info = JSON.parse(result.stdout);
45
+ const lines: string[] = [
46
+ `Bluetooth: ${info.AdapterPresent ? (info.Enabled ? 'ON' : 'OFF') : 'Not available'}`,
47
+ `Adapter: ${info.AdapterName}`,
48
+ ];
49
+
50
+ const devices = Array.isArray(info.Devices) ? info.Devices : info.Devices ? [info.Devices] : [];
51
+ if (devices.length > 0) {
52
+ lines.push('', 'Paired Devices:');
53
+ for (const d of devices) {
54
+ const icon = d.Connected ? '[CON]' : '[ ]';
55
+ lines.push(` ${icon} ${d.Name}`);
56
+ }
57
+ } else {
58
+ lines.push('', 'No paired devices.');
59
+ }
60
+
61
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
62
+ },
63
+ );
64
+
65
+ server.tool(
66
+ 'windows_bluetooth_control',
67
+ 'Enable/disable Bluetooth adapter or disconnect a device.',
68
+ {
69
+ action: z.enum(['enable', 'disable', 'disconnect']).describe('Action'),
70
+ device: z.string().optional().describe('Device name (for disconnect)'),
71
+ },
72
+ async ({ action, device }) => {
73
+ let ps: string;
74
+
75
+ switch (action) {
76
+ case 'enable':
77
+ ps = `
78
+ $adapter = Get-PnpDevice -Class Bluetooth -ErrorAction Stop | Where-Object { $_.FriendlyName -match 'Bluetooth' -and $_.Class -eq 'Bluetooth' } | Select-Object -First 1
79
+ if ($adapter) {
80
+ Enable-PnpDevice -InstanceId $adapter.InstanceId -Confirm:$false -ErrorAction Stop
81
+ "Bluetooth enabled"
82
+ } else { "No Bluetooth adapter found" }`;
83
+ break;
84
+ case 'disable':
85
+ ps = `
86
+ $adapter = Get-PnpDevice -Class Bluetooth -ErrorAction Stop | Where-Object { $_.FriendlyName -match 'Bluetooth' -and $_.Class -eq 'Bluetooth' } | Select-Object -First 1
87
+ if ($adapter) {
88
+ Disable-PnpDevice -InstanceId $adapter.InstanceId -Confirm:$false -ErrorAction Stop
89
+ "Bluetooth disabled"
90
+ } else { "No Bluetooth adapter found" }`;
91
+ break;
92
+ case 'disconnect':
93
+ if (!device) {
94
+ return { content: [{ type: 'text', text: 'Disconnect requires device name.' }], isError: true };
95
+ }
96
+ ps = `
97
+ $dev = Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | Where-Object { $_.FriendlyName -like '*${device.replace(/'/g, "''")}*' } | Select-Object -First 1
98
+ if ($dev) {
99
+ Disable-PnpDevice -InstanceId $dev.InstanceId -Confirm:$false -ErrorAction Stop
100
+ Start-Sleep -Seconds 1
101
+ Enable-PnpDevice -InstanceId $dev.InstanceId -Confirm:$false -ErrorAction Stop
102
+ "Disconnected and re-enabled: $($dev.FriendlyName)"
103
+ } else { "Device not found: ${device}" }`;
104
+ break;
105
+ }
106
+
107
+ const result = await runPowerShell(ps, { timeout: 15000 });
108
+ return {
109
+ content: [{ type: 'text', text: result.stdout || result.stderr }],
110
+ isError: result.exitCode !== 0,
111
+ };
112
+ },
113
+ );
114
+ }