mcp-server-commands 0.4.0 → 0.5.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.
package/LICENSE ADDED
@@ -0,0 +1,5 @@
1
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
2
+
3
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
4
+
5
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,14 @@
1
- # mcp-server-commands
1
+ ## Tools
2
+
3
+ Tools are for LLMs to request. Claude Sonnet 3.5 intelligently uses `run_command`. And, initial testing shows promising results with [Groq Desktop with MCP](https://github.com/groq/groq-desktop-beta) and `llama4` models.
4
+
5
+ Currently, just one command to rule them all!
2
6
 
3
- An MCP server to run commands.
7
+ - `run_command` - run a command, i.e. `hostname` or `ls -al` or `echo "hello world"` etc
8
+ - Returns `STDOUT` and `STDERR` as text
9
+ - Optional `stdin` parameter means your LLM can
10
+ - pass code in `stdin` to commands like `fish`, `bash`, `zsh`, `python`
11
+ - create files with `cat >> foo/bar.txt` from the text in `stdin`
4
12
 
5
13
  > [!WARNING]
6
14
  > Be careful what you ask this server to run!
@@ -8,15 +16,9 @@ An MCP server to run commands.
8
16
  > Permissions are dictated by the user that runs the server.
9
17
  > DO NOT run with `sudo`.
10
18
 
11
- ## Tools
12
-
13
- Tools are for LLMs to request, i.e. Claude Desktop app
19
+ ## Video walkthrough
14
20
 
15
- - `run_command` - run a command, i.e. `hostname` or `ls -al` or `echo "hello world"` etc
16
- - Returns STDOUT and STDERR as text
17
- - `run_script` - run a script! (i.e. `fish`, `bash`, `zsh`, `python`)
18
- - Let your LLM run the code it writes!
19
- - script is passed over STDIN
21
+ <a href="https://youtu.be/0-VPu1Pc18w"><img src="https://img.youtube.com/vi/0-VPu1Pc18w/maxresdefault.jpg" width="480" alt="YouTube Thumbnail"></a>
20
22
 
21
23
  ## Prompts
22
24
 
@@ -48,8 +50,12 @@ To use with Claude Desktop, add the server config:
48
50
  On MacOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
49
51
  On Windows: `%APPDATA%/Claude/claude_desktop_config.json`
50
52
 
53
+ Groq Desktop (beta, macOS) uses `~/Library/Application Support/groq-desktop-app/settings.json`
54
+
51
55
  ### Use the published npm package
52
56
 
57
+ Published to npm as [mcp-server-commands](https://www.npmjs.com/package/mcp-server-commands) using this [workflow](https://github.com/g0t4/mcp-server-commands/actions)
58
+
53
59
  ```json
54
60
  {
55
61
  "mcpServers": {
@@ -63,6 +69,8 @@ On Windows: `%APPDATA%/Claude/claude_desktop_config.json`
63
69
 
64
70
  ### Use a local build (repo checkout)
65
71
 
72
+ Make sure to run `npm run build`
73
+
66
74
  ```json
67
75
  {
68
76
  "mcpServers": {
@@ -93,7 +101,3 @@ npm run inspector
93
101
  ```
94
102
 
95
103
  The Inspector will provide a URL to access debugging tools in your browser.
96
-
97
- ## TODOs
98
-
99
- - Add some mechanism (likely in a new MCP server) to retain memory of past command failures, i.e. to use `python3` and not `python` and tie it to a machine or some context?
@@ -0,0 +1,8 @@
1
+ export function always_log(message, data) {
2
+ if (data) {
3
+ console.error(message + ": " + JSON.stringify(data));
4
+ }
5
+ else {
6
+ console.error(message);
7
+ }
8
+ }
@@ -2,16 +2,16 @@ import { exec } from "child_process";
2
2
  /**
3
3
  * Executes a file with the given arguments, piping input to stdin.
4
4
  * @param {string} interpreter - The file to execute.
5
- * @param {string} stdin_text - The string to pipe to stdin.
5
+ * @param {string} stdin - The string to pipe to stdin.
6
6
  * @returns {Promise<ExecResult>} A promise that resolves with the stdout and stderr of the command. `message` is provided on a failure to explain the error.
7
7
  */
8
- function execFileWithInput(interpreter, stdin_text, options) {
8
+ function execFileWithInput(interpreter, stdin, options) {
9
9
  // FYI for now, using `exec()` so the interpreter can have cmd+args AIO
10
10
  // could switch to `execFile()` to pass args array separately
11
11
  // TODO starts with fish too? "fish -..." PRN use a library to parse the command and determine this?
12
12
  if (interpreter.split(" ")[0] === "fish") {
13
13
  // PRN also check error from fish and add possible clarification to error message though there are legit ways to trigger that same error message! i.e. `fish .` which is not the same issue!
14
- return fishWorkaround(interpreter, stdin_text, options);
14
+ return fishWorkaround(interpreter, stdin, options);
15
15
  }
16
16
  return new Promise((resolve, reject) => {
17
17
  const child = exec(interpreter, options, (error, stdout, stderr) => {
@@ -22,21 +22,21 @@ function execFileWithInput(interpreter, stdin_text, options) {
22
22
  resolve({ stdout, stderr });
23
23
  }
24
24
  });
25
- if (stdin_text) {
25
+ if (stdin) {
26
26
  if (child.stdin === null) {
27
27
  reject(new Error("Unexpected failure: child.stdin is null"));
28
28
  return;
29
29
  }
30
- child.stdin.write(stdin_text);
30
+ child.stdin.write(stdin);
31
31
  child.stdin.end();
32
32
  }
33
33
  });
34
34
  }
35
- async function fishWorkaround(interpreter, script, options) {
35
+ async function fishWorkaround(interpreter, stdin, options) {
36
36
  // fish right now chokes on piped input (STDIN) + node's exec/spawn/etc, so lets use a workaround to echo the input
37
37
  // base64 encode thee input, then decode in pipeline
38
- const base64Script = Buffer.from(script).toString("base64");
39
- const command = `${interpreter} -c "echo ${base64Script} | base64 -d | fish"`;
38
+ const base64stdin = Buffer.from(stdin).toString("base64");
39
+ const command = `${interpreter} -c "echo ${base64stdin} | base64 -d | fish"`;
40
40
  return new Promise((resolve, reject) => {
41
41
  // const child = ... // careful with refactoring not to return that unused child
42
42
  exec(command, options, (error, stdout, stderr) => {
package/build/index.js CHANGED
@@ -1,10 +1,15 @@
1
1
  #!/usr/bin/env node
2
+ import os from 'os';
2
3
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
5
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
6
  import { exec } from "node:child_process";
6
7
  import { promisify } from "node:util";
7
- import { execFileWithInput } from "./exec-utils.js";
8
+ import { runCommand } from "./run-command.js";
9
+ import { createRequire } from "module";
10
+ import { always_log } from "./always_log.js";
11
+ const require = createRequire(import.meta.url);
12
+ const { name: package_name, version: package_version, } = require("../package.json");
8
13
  // TODO use .promises? in node api
9
14
  const execAsync = promisify(exec);
10
15
  let verbose = false;
@@ -13,8 +18,9 @@ if (process.argv.includes("--verbose")) {
13
18
  verbose = true;
14
19
  }
15
20
  const server = new Server({
16
- name: "mcp-server-commands",
17
- version: "0.4.0",
21
+ name: package_name,
22
+ version: package_version,
23
+ description: "Run commands on this " + os.platform() + " machine",
18
24
  }, {
19
25
  capabilities: {
20
26
  //resources: {},
@@ -23,14 +29,6 @@ const server = new Server({
23
29
  //logging: {}, // for logging messages that don't seem to work yet or I am doing them wrong
24
30
  },
25
31
  });
26
- function always_log(message, data) {
27
- if (data) {
28
- console.error(message + ": " + JSON.stringify(data));
29
- }
30
- else {
31
- console.error(message);
32
- }
33
- }
34
32
  if (verbose) {
35
33
  always_log("INFO: verbose logging enabled");
36
34
  }
@@ -75,6 +73,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
75
73
  tools: [
76
74
  {
77
75
  name: "run_command",
76
+ description: "Run a command on this " + os.platform() + " machine",
78
77
  inputSchema: {
79
78
  type: "object",
80
79
  properties: {
@@ -82,47 +81,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
82
81
  type: "string",
83
82
  description: "Command with args",
84
83
  },
85
- cwd: {
84
+ workdir: {
86
85
  // previous run_command calls can probe the filesystem and find paths to change to
87
86
  type: "string",
88
- description: "Current working directory, leave empty in most cases",
87
+ description: "Optional, current working directory",
88
+ },
89
+ stdin: {
90
+ type: "string",
91
+ description: "Optional, text to pipe into the command's STDIN. For example, pass a python script to python3. Or, pass text for a new file to the cat command to create it!",
89
92
  },
90
- // FYI using child_process.exec runs command in a shell, so you can pass a script here too but I still think separate tools would be helpful?
91
- // FYI gonna use execFile for run_script
92
- // - env - obscure cases where command takes a param only via an env var?
93
93
  // args to consider:
94
+ // - env - obscure cases where command takes a param only via an env var?
94
95
  // - timeout - lets just hard code this for now
95
- // - shell - (cmd/args) - for now use run_script for this case, also can just pass "fish -c 'command'" or "sh ..."
96
- // - stdin? though this borders on the run_script below
97
- // - capture_output (default true) - for now can just redirect to /dev/null - perhaps capture_stdout/capture_stderr
98
96
  },
99
97
  required: ["command"],
100
98
  },
101
99
  },
102
- // PRN tool to introspect the environment (i.e. windows vs linux vs mac, maybe default shell, etc?) - for now LLM can run commands and when they fail it can make adjustments accordingly - some cases where knowing this would help avoid dispatching erroneous commands (i.e. using free on linux, vm_stat on mac)
103
- {
104
- // TODO is run_script even needed if I were to add STDIN support to run_command above?
105
- name: "run_script",
106
- inputSchema: {
107
- type: "object",
108
- properties: {
109
- interpreter: {
110
- // TODO use shebang on *nix?
111
- type: "string",
112
- description: "Command with arguments. Script will be piped to stdin. Examples: bash, fish, zsh, python, or: bash --norc",
113
- },
114
- script: {
115
- type: "string",
116
- description: "Script to run",
117
- },
118
- cwd: {
119
- type: "string",
120
- description: "Current working directory",
121
- },
122
- },
123
- required: ["script"],
124
- },
125
- },
126
100
  ],
127
101
  };
128
102
  });
@@ -130,107 +104,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
130
104
  verbose_log("INFO: ToolRequest", request);
131
105
  switch (request.params.name) {
132
106
  case "run_command": {
133
- return {
134
- toolResult: await runCommand(request.params.arguments),
135
- };
136
- }
137
- case "run_script": {
138
- return {
139
- toolResult: await runScript(request.params.arguments),
140
- };
107
+ return await runCommand(request.params.arguments);
141
108
  }
142
109
  default:
143
110
  throw new Error("Unknown tool");
144
111
  }
145
112
  });
146
- async function runCommand(args) {
147
- const command = String(args?.command);
148
- if (!command) {
149
- throw new Error("Command is required");
150
- }
151
- const options = {};
152
- if (args?.cwd) {
153
- options.cwd = String(args.cwd);
154
- // ENOENT is thrown if the cwd doesn't exist, and I think LLMs can understand that?
155
- }
156
- try {
157
- const result = await execAsync(command, options);
158
- return {
159
- isError: false,
160
- content: messagesFor(result),
161
- };
162
- }
163
- catch (error) {
164
- // TODO catch for other errors, not just ExecException
165
- // FYI failure may not always be a bad thing if for example checking for a file to exist so just keep that in mind in terms of logging?
166
- const response = {
167
- isError: true,
168
- content: messagesFor(error),
169
- };
170
- always_log("WARN: run_command failed", response);
171
- return response;
172
- }
173
- }
174
- async function runScript(args) {
175
- const interpreter = String(args?.interpreter);
176
- if (!interpreter) {
177
- throw new Error("Interpreter is required");
178
- }
179
- const options = {
180
- //const options = {
181
- // constrains typescript too, to string based overload
182
- encoding: "utf8",
183
- };
184
- if (args?.cwd) {
185
- options.cwd = String(args.cwd);
186
- // ENOENT is thrown if the cwd doesn't exist, and I think LLMs can understand that?
187
- }
188
- const script = String(args?.script);
189
- if (!script) {
190
- throw new Error("Script is required");
191
- }
192
- try {
193
- const result = await execFileWithInput(interpreter, script, options);
194
- return {
195
- isError: false,
196
- content: messagesFor(result),
197
- };
198
- }
199
- catch (error) {
200
- const response = {
201
- isError: true,
202
- content: messagesFor(error),
203
- };
204
- always_log("WARN: run_script failed", response);
205
- return response;
206
- }
207
- }
208
- function messagesFor(result) {
209
- const messages = [];
210
- if (result.message) {
211
- messages.push({
212
- // most of the time this is gonna match stderr, TODO do I want/need both error and stderr?
213
- type: "text",
214
- text: result.message,
215
- name: "ERROR",
216
- });
217
- }
218
- if (result.stdout) {
219
- messages.push({
220
- type: "text",
221
- text: result.stdout,
222
- name: "STDOUT",
223
- });
224
- }
225
- if (result.stderr) {
226
- messages.push({
227
- type: "text",
228
- text: result.stderr,
229
- name: "STDERR",
230
- });
231
- }
232
- return messages;
233
- }
234
113
  server.setRequestHandler(ListPromptsRequestSchema, async () => {
235
114
  verbose_log("INFO: ListPrompts");
236
115
  return {
@@ -243,6 +122,7 @@ server.setRequestHandler(ListPromptsRequestSchema, async () => {
243
122
  name: "command",
244
123
  required: true,
245
124
  },
125
+ // if I care to keep the prompt tools then add stdin?
246
126
  ],
247
127
  },
248
128
  ],
@@ -257,9 +137,9 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
257
137
  if (!command) {
258
138
  throw new Error("Command is required");
259
139
  }
260
- // Is it possible/feasible to pass a path for the CWD when running the command?
140
+ // Is it possible/feasible to pass a path for the workdir when running the command?
261
141
  // - currently it uses / (yikez)
262
- // - IMO makes more sense to have it be based on the Zed CWD of each project
142
+ // - IMO makes more sense to have it be based on the Zed workdir of each project
263
143
  // - Fallback could be to configure on server level (i.e. home dir of current user) - perhaps CLI arg? (thinking of zed's context_servers config section)
264
144
  const { stdout, stderr } = await execAsync(command);
265
145
  // TODO gracefully handle errors and turn them into a prompt message that can be used by LLM to troubleshoot the issue, currently errors result in nothing inserted into the prompt and instead it shows the Zed's chat panel as a failure
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Converts an ExecResult into an array of TextContent messages.
3
+ */
4
+ export function messagesFor(result) {
5
+ const messages = [];
6
+ if (result.message) {
7
+ messages.push({
8
+ type: "text",
9
+ text: result.message,
10
+ name: "ERROR",
11
+ });
12
+ }
13
+ if (result.stdout) {
14
+ messages.push({
15
+ type: "text",
16
+ text: result.stdout,
17
+ name: "STDOUT",
18
+ });
19
+ }
20
+ if (result.stderr) {
21
+ messages.push({
22
+ type: "text",
23
+ text: result.stderr,
24
+ name: "STDERR",
25
+ });
26
+ }
27
+ return messages;
28
+ }
@@ -0,0 +1,44 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { execFileWithInput } from "./exec-utils.js";
4
+ import { always_log } from "./always_log.js";
5
+ import { messagesFor } from "./messages.js";
6
+ const execAsync = promisify(exec);
7
+ async function execute(command, stdin, options) {
8
+ if (!stdin) {
9
+ return await execAsync(command, options);
10
+ }
11
+ return await execFileWithInput(command, stdin, options);
12
+ }
13
+ /**
14
+ * Executes a command and returns the result as CallToolResult.
15
+ */
16
+ export async function runCommand(args) {
17
+ const command = args?.command;
18
+ if (!command) {
19
+ const message = "Command is required, current value: " + command;
20
+ return {
21
+ isError: true,
22
+ content: [{ type: "text", text: message }],
23
+ };
24
+ }
25
+ const options = { encoding: "utf8" };
26
+ if (args?.workdir) {
27
+ options.cwd = String(args.workdir);
28
+ }
29
+ const stdin = args?.stdin;
30
+ try {
31
+ const result = await execute(command, stdin, options);
32
+ return {
33
+ content: messagesFor(result),
34
+ };
35
+ }
36
+ catch (error) {
37
+ const response = {
38
+ isError: true,
39
+ content: messagesFor(error),
40
+ };
41
+ always_log("WARN: run_command failed", response);
42
+ return response;
43
+ }
44
+ }
package/package.json CHANGED
@@ -1,33 +1,33 @@
1
1
  {
2
- "name": "mcp-server-commands",
3
- "version": "0.4.0",
4
- "description": "An MCP server to run arbitrary commands",
5
- "private": false,
6
- "type": "module",
7
- "bin": {
8
- "mcp-server-commands": "./build/index.js"
9
- },
10
- "files": [
11
- "build"
12
- ],
13
- "scripts": {
14
- "clean": "rm -rf build",
15
- "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
16
- "prepare": "npm run build",
17
- "watch": "npm run build && tsc --watch",
18
- "inspector": "npx @modelcontextprotocol/inspector build/index.js",
19
- "test": "jest",
20
- "test:watch": "jest --watch",
21
- "test:integration": "jest tests/integration"
22
- },
23
- "dependencies": {
24
- "@modelcontextprotocol/sdk": "0.6.0"
25
- },
26
- "devDependencies": {
27
- "@types/jest": "^29.5.11",
28
- "@types/node": "^20.11.24",
29
- "jest": "^29.7.0",
30
- "ts-jest": "^29.1.1",
31
- "typescript": "^5.3.3"
32
- }
2
+ "name": "mcp-server-commands",
3
+ "version": "0.5.0",
4
+ "description": "An MCP server to run arbitrary commands",
5
+ "private": false,
6
+ "type": "module",
7
+ "bin": {
8
+ "mcp-server-commands": "./build/index.js"
9
+ },
10
+ "files": [
11
+ "build"
12
+ ],
13
+ "scripts": {
14
+ "clean": "rm -rf build",
15
+ "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
16
+ "prepare": "npm run build",
17
+ "watch": "npm run build && tsc --watch",
18
+ "inspector": "npx @modelcontextprotocol/inspector build/index.js",
19
+ "test": "jest",
20
+ "test:watch": "jest --watch",
21
+ "test:integration": "jest tests/integration"
22
+ },
23
+ "dependencies": {
24
+ "@modelcontextprotocol/sdk": "1.9.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/jest": "^29.5.14",
28
+ "@types/node": "^22.14.1",
29
+ "jest": "^29.7.0",
30
+ "ts-jest": "^29.3.2",
31
+ "typescript": "^5.8.3"
32
+ }
33
33
  }