@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,124 @@
1
+ /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ *
4
+ * Tools: windows_drives (#24), windows_file_search (#25)
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 registerDriveTools(server: McpServer): void {
12
+ server.tool(
13
+ 'windows_drives',
14
+ 'List all drives/volumes with type, label, capacity, free space, and usage percentage.',
15
+ {},
16
+ async () => {
17
+ const ps = `
18
+ Get-CimInstance Win32_LogicalDisk | ForEach-Object {
19
+ $typeMap = @{0='Unknown';1='No Root';2='Removable';3='Local';4='Network';5='CD/DVD';6='RAM Disk'}
20
+ [PSCustomObject]@{
21
+ Drive = $_.DeviceID
22
+ Label = $_.VolumeName
23
+ Type = $typeMap[[int]$_.DriveType]
24
+ FileSystem = $_.FileSystem
25
+ TotalGB = if ($_.Size) { [math]::Round($_.Size / 1GB, 1) } else { 0 }
26
+ FreeGB = if ($_.FreeSpace) { [math]::Round($_.FreeSpace / 1GB, 1) } else { 0 }
27
+ UsedPct = if ($_.Size -gt 0) { [math]::Round(($_.Size - $_.FreeSpace) / $_.Size * 100, 1) } else { 0 }
28
+ }
29
+ } | ConvertTo-Json -Depth 3 -Compress`;
30
+
31
+ const result = await runPowerShell(ps, { timeout: 10000 });
32
+ if (result.exitCode !== 0) {
33
+ return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
34
+ }
35
+
36
+ const drives = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)];
37
+
38
+ const lines = drives.map((d: { Drive: string; Label: string; Type: string; FileSystem: string; TotalGB: number; FreeGB: number; UsedPct: number }) => {
39
+ const bar = d.TotalGB > 0 ? makeBar(d.UsedPct) : ' ';
40
+ return `${d.Drive} ${(d.Label || '').padEnd(15).slice(0, 15)} ${d.Type.padEnd(10)} ${d.FileSystem?.padEnd(5) || ' '} ${String(d.FreeGB).padStart(8)}/${String(d.TotalGB).padStart(8)} GB ${bar} ${d.UsedPct}%`;
41
+ });
42
+
43
+ const header = `Drv ${'Label'.padEnd(15)} ${'Type'.padEnd(10)} FS ${'Free'.padStart(8)}/${'Total'.padStart(8)} Usage`;
44
+ return {
45
+ content: [{ type: 'text', text: `${header}\n${'─'.repeat(90)}\n${lines.join('\n')}` }],
46
+ };
47
+ },
48
+ );
49
+
50
+ server.tool(
51
+ 'windows_file_search',
52
+ 'Search for files using Windows Search index (fast) or filesystem walk (fallback). Search by name or content.',
53
+ {
54
+ query: z.string().describe('Search query (filename pattern or content text)'),
55
+ path: z.string().default('C:\\').describe('Directory to search in'),
56
+ type: z.enum(['name', 'content']).default('name').describe('Search by filename or file content'),
57
+ extension: z.string().optional().describe('File extension filter (e.g. ".txt", ".log")'),
58
+ limit: z.number().default(30).describe('Max results'),
59
+ },
60
+ async ({ query, path, type, extension, limit }) => {
61
+ const extFilter = extension
62
+ ? `| Where-Object { $_.Extension -eq '${extension.replace(/'/g, "''")}' }`
63
+ : '';
64
+
65
+ let ps: string;
66
+ if (type === 'name') {
67
+ ps = `
68
+ Get-ChildItem -Path '${path.replace(/'/g, "''")}' -Recurse -File -ErrorAction SilentlyContinue ${extFilter ? '' : ''} |
69
+ Where-Object { $_.Name -like '*${query.replace(/'/g, "''")}*' } ${extFilter} |
70
+ Select-Object -First ${limit} |
71
+ ForEach-Object {
72
+ [PSCustomObject]@{
73
+ Path = $_.FullName
74
+ Size = $_.Length
75
+ Modified = $_.LastWriteTime.ToString('yyyy-MM-dd HH:mm')
76
+ Extension = $_.Extension
77
+ }
78
+ } | ConvertTo-Json -Depth 3 -Compress`;
79
+ } else {
80
+ ps = `
81
+ Get-ChildItem -Path '${path.replace(/'/g, "''")}' -Recurse -File -ErrorAction SilentlyContinue ${extFilter} |
82
+ Select-String -Pattern '${query.replace(/'/g, "''")}' -List -ErrorAction SilentlyContinue |
83
+ Select-Object -First ${limit} |
84
+ ForEach-Object {
85
+ [PSCustomObject]@{
86
+ Path = $_.Path
87
+ Line = $_.LineNumber
88
+ Match = $_.Line.Trim().Substring(0, [math]::Min($_.Line.Trim().Length, 120))
89
+ }
90
+ } | ConvertTo-Json -Depth 3 -Compress`;
91
+ }
92
+
93
+ const result = await runPowerShell(ps, { timeout: 60000 });
94
+ if (result.exitCode !== 0) {
95
+ return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
96
+ }
97
+
98
+ if (!result.stdout) {
99
+ return { content: [{ type: 'text', text: 'No results found.' }] };
100
+ }
101
+
102
+ const results = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)];
103
+
104
+ let text: string;
105
+ if (type === 'name') {
106
+ text = results.map((r: { Path: string; Size: number; Modified: string }) =>
107
+ `${String(r.Size).padStart(10)} ${r.Modified} ${r.Path}`,
108
+ ).join('\n');
109
+ text = `${'Size'.padStart(10)} ${'Modified'.padEnd(16)} Path\n${'─'.repeat(80)}\n${text}`;
110
+ } else {
111
+ text = results.map((r: { Path: string; Line: number; Match: string }) =>
112
+ `${r.Path}:${r.Line}: ${r.Match}`,
113
+ ).join('\n');
114
+ }
115
+
116
+ return { content: [{ type: 'text', text: `${results.length} result(s):\n\n${text}` }] };
117
+ },
118
+ );
119
+ }
120
+
121
+ function makeBar(pct: number): string {
122
+ const filled = Math.round(pct / 10);
123
+ return '[' + '█'.repeat(filled) + '░'.repeat(10 - filled) + ']';
124
+ }
@@ -0,0 +1,146 @@
1
+ /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ *
4
+ * Tools: windows_env_get (#31), windows_env_set (#32)
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 registerEnvironmentTools(server: McpServer): void {
12
+ server.tool(
13
+ 'windows_env_get',
14
+ 'Get environment variables. Can retrieve a specific variable or list all (user, system, or both).',
15
+ {
16
+ name: z.string().optional().describe('Variable name (omit to list all)'),
17
+ scope: z.enum(['user', 'system', 'both']).default('both').describe('Variable scope'),
18
+ },
19
+ async ({ name, scope }) => {
20
+ if (name) {
21
+ if (name.toUpperCase() === 'PATH') {
22
+ const ps = `
23
+ $userPath = [Environment]::GetEnvironmentVariable('PATH', 'User')
24
+ $sysPath = [Environment]::GetEnvironmentVariable('PATH', 'Machine')
25
+ [PSCustomObject]@{
26
+ UserPATH = ($userPath -split ';' | Where-Object { $_ })
27
+ SystemPATH = ($sysPath -split ';' | Where-Object { $_ })
28
+ } | ConvertTo-Json -Depth 3 -Compress`;
29
+ const result = await runPowerShell(ps, { timeout: 10000 });
30
+ if (result.exitCode !== 0) {
31
+ return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
32
+ }
33
+ const p = JSON.parse(result.stdout);
34
+ const userPaths = Array.isArray(p.UserPATH) ? p.UserPATH : [p.UserPATH].filter(Boolean);
35
+ const sysPaths = Array.isArray(p.SystemPATH) ? p.SystemPATH : [p.SystemPATH].filter(Boolean);
36
+ return {
37
+ content: [{
38
+ type: 'text',
39
+ text: `System PATH (${sysPaths.length} entries):\n${sysPaths.map((p: string) => ` ${p}`).join('\n')}\n\nUser PATH (${userPaths.length} entries):\n${userPaths.map((p: string) => ` ${p}`).join('\n')}`,
40
+ }],
41
+ };
42
+ }
43
+
44
+ const ps = `
45
+ $user = [Environment]::GetEnvironmentVariable('${name.replace(/'/g, "''")}', 'User')
46
+ $sys = [Environment]::GetEnvironmentVariable('${name.replace(/'/g, "''")}', 'Machine')
47
+ $proc = [Environment]::GetEnvironmentVariable('${name.replace(/'/g, "''")}', 'Process')
48
+ [PSCustomObject]@{ Name = '${name.replace(/'/g, "''")}'; User = $user; System = $sys; Process = $proc } | ConvertTo-Json -Compress`;
49
+
50
+ const result = await runPowerShell(ps, { timeout: 10000 });
51
+ if (result.exitCode !== 0) {
52
+ return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
53
+ }
54
+ const v = JSON.parse(result.stdout);
55
+ const lines = [`${v.Name}:`];
56
+ if (v.System !== null) lines.push(` System: ${v.System}`);
57
+ if (v.User !== null) lines.push(` User: ${v.User}`);
58
+ if (v.Process !== null && v.Process !== v.System && v.Process !== v.User) lines.push(` Process: ${v.Process}`);
59
+ if (v.System === null && v.User === null) lines.push(' (not set)');
60
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
61
+ }
62
+
63
+ // List all
64
+ const scopes = scope === 'both' ? ['User', 'Machine'] : [scope === 'user' ? 'User' : 'Machine'];
65
+ const parts: string[] = [];
66
+
67
+ for (const s of scopes) {
68
+ const ps = `[Environment]::GetEnvironmentVariables('${s}').GetEnumerator() | Sort-Object Name | ForEach-Object { [PSCustomObject]@{ Name = $_.Name; Value = $_.Value } } | ConvertTo-Json -Depth 3 -Compress`;
69
+ const result = await runPowerShell(ps, { timeout: 10000 });
70
+ if (result.exitCode !== 0) continue;
71
+ if (!result.stdout) continue;
72
+
73
+ const vars = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)];
74
+ const lines = vars.map((v: { Name: string; Value: string }) =>
75
+ ` ${v.Name.padEnd(30)} ${(v.Value || '').slice(0, 60)}`,
76
+ );
77
+ parts.push(`${s} Variables (${vars.length}):\n${lines.join('\n')}`);
78
+ }
79
+
80
+ return { content: [{ type: 'text', text: parts.join('\n\n') }] };
81
+ },
82
+ );
83
+
84
+ server.tool(
85
+ 'windows_env_set',
86
+ 'Set or remove a persistent environment variable (user or system scope).',
87
+ {
88
+ name: z.string().describe('Variable name'),
89
+ value: z.string().optional().describe('Value to set (omit with action=remove to delete)'),
90
+ scope: z.enum(['user', 'system']).default('user').describe('Variable scope'),
91
+ action: z.enum(['set', 'remove', 'append_path', 'prepend_path']).default('set').describe('Action'),
92
+ },
93
+ async ({ name, value, scope, action }) => {
94
+ const target = scope === 'user' ? 'User' : 'Machine';
95
+ let ps: string;
96
+
97
+ switch (action) {
98
+ case 'set':
99
+ if (!value) {
100
+ return { content: [{ type: 'text', text: 'Set requires a value.' }], isError: true };
101
+ }
102
+ ps = `[Environment]::SetEnvironmentVariable('${name.replace(/'/g, "''")}', '${value.replace(/'/g, "''")}', '${target}'); "Set ${name}=${value} (${target})"`;
103
+ break;
104
+ case 'remove':
105
+ ps = `[Environment]::SetEnvironmentVariable('${name.replace(/'/g, "''")}', $null, '${target}'); "Removed ${name} (${target})"`;
106
+ break;
107
+ case 'append_path':
108
+ if (!value) {
109
+ return { content: [{ type: 'text', text: 'append_path requires a value.' }], isError: true };
110
+ }
111
+ ps = `
112
+ $current = [Environment]::GetEnvironmentVariable('PATH', '${target}')
113
+ $entries = $current -split ';' | Where-Object { $_ }
114
+ if ('${value.replace(/'/g, "''")}' -notin $entries) {
115
+ $new = ($entries + '${value.replace(/'/g, "''")}') -join ';'
116
+ [Environment]::SetEnvironmentVariable('PATH', $new, '${target}')
117
+ "Appended '${value}' to ${target} PATH"
118
+ } else {
119
+ "'${value}' already in ${target} PATH"
120
+ }`;
121
+ break;
122
+ case 'prepend_path':
123
+ if (!value) {
124
+ return { content: [{ type: 'text', text: 'prepend_path requires a value.' }], isError: true };
125
+ }
126
+ ps = `
127
+ $current = [Environment]::GetEnvironmentVariable('PATH', '${target}')
128
+ $entries = $current -split ';' | Where-Object { $_ }
129
+ if ('${value.replace(/'/g, "''")}' -notin $entries) {
130
+ $new = ('${value.replace(/'/g, "''")}' + ';' + ($entries -join ';'))
131
+ [Environment]::SetEnvironmentVariable('PATH', $new, '${target}')
132
+ "Prepended '${value}' to ${target} PATH"
133
+ } else {
134
+ "'${value}' already in ${target} PATH"
135
+ }`;
136
+ break;
137
+ }
138
+
139
+ const result = await runPowerShell(ps, { timeout: 10000 });
140
+ return {
141
+ content: [{ type: 'text', text: result.stdout || result.stderr }],
142
+ isError: result.exitCode !== 0,
143
+ };
144
+ },
145
+ );
146
+ }
@@ -0,0 +1,35 @@
1
+ /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ *
4
+ * Tool: windows_execute — Execute shell commands (#1)
5
+ */
6
+
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
+ import { z } from 'zod';
9
+ import { runShell } from '../shell.js';
10
+
11
+ export function registerExecuteTools(server: McpServer): void {
12
+ server.tool(
13
+ 'windows_execute',
14
+ 'Execute a shell command (PowerShell, cmd, or bash). Returns stdout, stderr, and exit code.',
15
+ {
16
+ command: z.string().describe('The command to execute'),
17
+ shell: z.enum(['pwsh', 'cmd', 'bash']).default('pwsh').describe('Shell to use'),
18
+ timeout: z.number().optional().describe('Timeout in milliseconds (default 30000)'),
19
+ cwd: z.string().optional().describe('Working directory'),
20
+ },
21
+ async ({ command, shell, timeout, cwd }) => {
22
+ const result = await runShell(command, { shell, timeout, cwd });
23
+ const parts: string[] = [];
24
+
25
+ if (result.stdout) parts.push(result.stdout);
26
+ if (result.stderr) parts.push(`[stderr]\n${result.stderr}`);
27
+ parts.push(`[exit code: ${result.exitCode}]`);
28
+
29
+ return {
30
+ content: [{ type: 'text', text: parts.join('\n\n') }],
31
+ isError: result.exitCode !== 0,
32
+ };
33
+ },
34
+ );
35
+ }
@@ -0,0 +1,273 @@
1
+ /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ *
4
+ * Tools: windows_file_read (#36), windows_file_write (#37),
5
+ * windows_file_edit (#38), windows_search (#39)
6
+ */
7
+
8
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { z } from 'zod';
10
+ import { readFile, writeFile, stat, mkdir } from 'node:fs/promises';
11
+ import { dirname, resolve, extname } from 'node:path';
12
+ import { runPowerShell } from '../shell.js';
13
+
14
+ export function registerFilesystemTools(server: McpServer): void {
15
+
16
+ // ── windows_file_read ────────────────────────────────────────────────
17
+
18
+ server.tool(
19
+ 'windows_file_read',
20
+ 'Read a file with line pagination. Supports text, images (base64), and binary detection. Use negative offset for tail behavior.',
21
+ {
22
+ path: z.string().describe('Absolute file path'),
23
+ offset: z.number().default(0).describe('Line offset (0-based, negative = from end)'),
24
+ length: z.number().default(200).describe('Number of lines to return'),
25
+ },
26
+ async ({ path: filePath, offset, length }) => {
27
+ try {
28
+ const absPath = resolve(filePath);
29
+ const ext = extname(absPath).toLowerCase();
30
+
31
+ // Image files → base64
32
+ if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.svg'].includes(ext)) {
33
+ const data = await readFile(absPath);
34
+ const mimeMap: Record<string, string> = {
35
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
36
+ '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp',
37
+ '.ico': 'image/x-icon', '.svg': 'image/svg+xml',
38
+ };
39
+ return {
40
+ content: [{
41
+ type: 'image' as const,
42
+ data: data.toString('base64'),
43
+ mimeType: mimeMap[ext] || 'application/octet-stream',
44
+ }],
45
+ };
46
+ }
47
+
48
+ // Text files → line pagination
49
+ const content = await readFile(absPath, 'utf-8');
50
+ const allLines = content.split('\n');
51
+ const total = allLines.length;
52
+
53
+ let start: number;
54
+ if (offset < 0) {
55
+ start = Math.max(0, total + offset);
56
+ } else {
57
+ start = Math.min(offset, total);
58
+ }
59
+ const end = Math.min(start + length, total);
60
+ const lines = allLines.slice(start, end);
61
+
62
+ const numbered = lines.map((line, i) => `${String(start + i + 1).padStart(5)} ${line}`);
63
+ const header = `${absPath} — lines ${start + 1}-${end} of ${total}`;
64
+
65
+ return {
66
+ content: [{ type: 'text', text: `${header}\n${'─'.repeat(60)}\n${numbered.join('\n')}` }],
67
+ };
68
+ } catch (err) {
69
+ return { content: [{ type: 'text', text: `Error reading file: ${err}` }], isError: true };
70
+ }
71
+ },
72
+ );
73
+
74
+ // ── windows_file_write ───────────────────────────────────────────────
75
+
76
+ server.tool(
77
+ 'windows_file_write',
78
+ 'Write or append to a text file. Creates parent directories if needed.',
79
+ {
80
+ path: z.string().describe('Absolute file path'),
81
+ content: z.string().describe('Content to write'),
82
+ mode: z.enum(['write', 'append']).default('write').describe('Write mode'),
83
+ },
84
+ async ({ path: filePath, content, mode }) => {
85
+ try {
86
+ const absPath = resolve(filePath);
87
+ await mkdir(dirname(absPath), { recursive: true });
88
+
89
+ if (mode === 'append') {
90
+ const existing = await readFile(absPath, 'utf-8').catch(() => '');
91
+ await writeFile(absPath, existing + content, 'utf-8');
92
+ } else {
93
+ await writeFile(absPath, content, 'utf-8');
94
+ }
95
+
96
+ const info = await stat(absPath);
97
+ return {
98
+ content: [{
99
+ type: 'text',
100
+ text: `Written: ${absPath} (${info.size} bytes, mode: ${mode})`,
101
+ }],
102
+ };
103
+ } catch (err) {
104
+ return { content: [{ type: 'text', text: `Error writing file: ${err}` }], isError: true };
105
+ }
106
+ },
107
+ );
108
+
109
+ // ── windows_file_edit ────────────────────────────────────────────────
110
+
111
+ server.tool(
112
+ 'windows_file_edit',
113
+ 'Surgical file edit with find/replace. Validates expected replacement count.',
114
+ {
115
+ path: z.string().describe('Absolute file path'),
116
+ old_string: z.string().describe('Text to find'),
117
+ new_string: z.string().describe('Replacement text'),
118
+ expected_count: z.number().optional().describe('Expected number of replacements (fails if mismatch)'),
119
+ replace_all: z.boolean().default(false).describe('Replace all occurrences'),
120
+ },
121
+ async ({ path: filePath, old_string, new_string, expected_count, replace_all }) => {
122
+ try {
123
+ const absPath = resolve(filePath);
124
+ const content = await readFile(absPath, 'utf-8');
125
+
126
+ // Count occurrences
127
+ let count = 0;
128
+ let idx = 0;
129
+ while ((idx = content.indexOf(old_string, idx)) !== -1) {
130
+ count++;
131
+ idx += old_string.length;
132
+ }
133
+
134
+ if (count === 0) {
135
+ // Try to find near-matches for helpful error
136
+ const lines = content.split('\n');
137
+ const needle = old_string.trim().split('\n')[0].trim();
138
+ const nearMatches = lines
139
+ .map((line, i) => ({ line: line.trim(), num: i + 1 }))
140
+ .filter(({ line }) => {
141
+ if (!needle) return false;
142
+ // Simple similarity: shared words
143
+ const words = needle.toLowerCase().split(/\s+/);
144
+ const lineWords = line.toLowerCase().split(/\s+/);
145
+ const shared = words.filter(w => lineWords.includes(w)).length;
146
+ return shared >= Math.ceil(words.length * 0.5);
147
+ })
148
+ .slice(0, 3);
149
+
150
+ let msg = `No matches found for the specified text.`;
151
+ if (nearMatches.length > 0) {
152
+ msg += `\n\nNear matches:\n${nearMatches.map(m => ` Line ${m.num}: ${m.line}`).join('\n')}`;
153
+ }
154
+ return { content: [{ type: 'text', text: msg }], isError: true };
155
+ }
156
+
157
+ if (expected_count !== undefined && count !== expected_count) {
158
+ return {
159
+ content: [{
160
+ type: 'text',
161
+ text: `Expected ${expected_count} occurrence(s) but found ${count}. No changes made.`,
162
+ }],
163
+ isError: true,
164
+ };
165
+ }
166
+
167
+ let result: string;
168
+ if (replace_all) {
169
+ result = content.split(old_string).join(new_string);
170
+ } else {
171
+ const pos = content.indexOf(old_string);
172
+ result = content.slice(0, pos) + new_string + content.slice(pos + old_string.length);
173
+ }
174
+
175
+ await writeFile(absPath, result, 'utf-8');
176
+
177
+ const replaced = replace_all ? count : 1;
178
+ return {
179
+ content: [{
180
+ type: 'text',
181
+ text: `Replaced ${replaced} occurrence(s) in ${absPath}`,
182
+ }],
183
+ };
184
+ } catch (err) {
185
+ return { content: [{ type: 'text', text: `Error editing file: ${err}` }], isError: true };
186
+ }
187
+ },
188
+ );
189
+
190
+ // ── windows_search ───────────────────────────────────────────────────
191
+
192
+ server.tool(
193
+ 'windows_search',
194
+ 'Search for files by name pattern or search file contents. Uses ripgrep if available, falls back to PowerShell.',
195
+ {
196
+ type: z.enum(['files', 'content']).describe('Search type: "files" for filename, "content" for inside files'),
197
+ pattern: z.string().describe('Search pattern (glob for files, regex or literal for content)'),
198
+ path: z.string().default('.').describe('Directory to search in'),
199
+ case_sensitive: z.boolean().default(false).describe('Case-sensitive search'),
200
+ file_pattern: z.string().optional().describe('Filter files by pattern (e.g. "*.ts") — only for content search'),
201
+ context_lines: z.number().default(0).describe('Lines of context around content matches'),
202
+ limit: z.number().default(50).describe('Max results'),
203
+ },
204
+ async ({ type, pattern, path: searchPath, case_sensitive, file_pattern, context_lines, limit }) => {
205
+ const absPath = resolve(searchPath);
206
+
207
+ if (type === 'files') {
208
+ const caseSense = case_sensitive ? '' : '-i';
209
+ const ps = `
210
+ Get-ChildItem -Path '${absPath.replace(/'/g, "''")}' -Recurse -File -ErrorAction SilentlyContinue |
211
+ Where-Object { $_.Name ${caseSense ? '-cmatch' : '-match'} '${pattern.replace(/'/g, "''")}' } |
212
+ Select-Object -First ${limit} |
213
+ ForEach-Object {
214
+ "$($_.Length.ToString().PadLeft(10)) $($_.LastWriteTime.ToString('yyyy-MM-dd HH:mm')) $($_.FullName)"
215
+ }`;
216
+ const result = await runPowerShell(ps, { timeout: 30000 });
217
+ if (result.exitCode !== 0) {
218
+ return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
219
+ }
220
+ const lines = result.stdout ? result.stdout.split('\n') : [];
221
+ return {
222
+ content: [{
223
+ type: 'text',
224
+ text: lines.length > 0
225
+ ? `${lines.length} file(s) found:\n\n${' Size Modified Path'}\n${lines.join('\n')}`
226
+ : 'No files found.',
227
+ }],
228
+ };
229
+ }
230
+
231
+ // Content search — try ripgrep first, fall back to PowerShell
232
+ const caseFlag = case_sensitive ? '' : '-i';
233
+ const contextFlag = context_lines > 0 ? `-C ${context_lines}` : '';
234
+ const fileFlag = file_pattern ? `--glob '${file_pattern}'` : '';
235
+
236
+ // Try rg first
237
+ const rgCmd = `rg ${caseFlag} ${contextFlag} ${fileFlag} --max-count 5 -n '${pattern.replace(/'/g, "\\'")}' '${absPath.replace(/'/g, "\\'")}'`;
238
+ const rgResult = await runPowerShell(`& { ${rgCmd} } 2>$null | Select-Object -First ${limit * 3}`, { timeout: 30000 });
239
+
240
+ if (rgResult.exitCode === 0 && rgResult.stdout) {
241
+ const lines = rgResult.stdout.split('\n');
242
+ return {
243
+ content: [{
244
+ type: 'text',
245
+ text: `Content search results (ripgrep):\n\n${lines.join('\n')}`,
246
+ }],
247
+ };
248
+ }
249
+
250
+ // Fallback: PowerShell Select-String
251
+ const fileFilter = file_pattern ? `-Include '${file_pattern}'` : '';
252
+ const ps = `
253
+ Get-ChildItem -Path '${absPath.replace(/'/g, "''")}' -Recurse -File ${fileFilter} -ErrorAction SilentlyContinue |
254
+ Select-String -Pattern '${pattern.replace(/'/g, "''")}' ${case_sensitive ? '-CaseSensitive' : ''} -Context ${context_lines} |
255
+ Select-Object -First ${limit} |
256
+ ForEach-Object { "$($_.Path):$($_.LineNumber): $($_.Line.Trim())" }`;
257
+
258
+ const result = await runPowerShell(ps, { timeout: 60000 });
259
+ if (result.exitCode !== 0) {
260
+ return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
261
+ }
262
+ const lines = result.stdout ? result.stdout.split('\n') : [];
263
+ return {
264
+ content: [{
265
+ type: 'text',
266
+ text: lines.length > 0
267
+ ? `${lines.length} match(es) found:\n\n${lines.join('\n')}`
268
+ : 'No matches found.',
269
+ }],
270
+ };
271
+ },
272
+ );
273
+ }