mcp-server-commands 0.2.1 → 0.3.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/README.md CHANGED
@@ -1,17 +1,28 @@
1
1
  # mcp-server-commands
2
2
 
3
- An MCP server to run commands and includ the output in chat history.
3
+ An MCP server to run commands.
4
+
5
+ > [!WARNING]
6
+ > Be careful what you ask this server to run!
7
+ > In Claude Desktop app, use `Approve Once` (not `Allow for This Chat`) so you can review each command, use `Deny` if you don't trust the command.
8
+ > Permissions are dictated by the user that runs the server.
9
+ > DO NOT run with `sudo`.
4
10
 
5
11
  ## Tools
6
12
 
13
+ Tools are for LLMs to request, i.e. Claude Desktop app
14
+
7
15
  - `run_command` - run a command, i.e. `hostname` or `ls -al` or `echo "hello world"` etc
8
16
  - Returns STDOUT and STDERR as text
9
- - for LLMs to request tool use and get back the command output, i.e. Claude Desktop app
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
10
20
 
11
21
  ## Prompts
12
22
 
13
- - `run_command` - include the output of a command in the chat history
14
- - for users to include relevant commands in chat history, i.e. via `Zed`'s slash commands
23
+ Prompts are for users to include in chat history, i.e. via `Zed`'s slash commands (in its AI Chat panel)
24
+
25
+ - `run_command` - generate a prompt message with the command output
15
26
 
16
27
  ## Development
17
28
 
@@ -0,0 +1,53 @@
1
+ import { exec } from "child_process";
2
+ /**
3
+ * Executes a file with the given arguments, piping input to stdin.
4
+ * @param {string} interpreter - The file to execute.
5
+ * @param {string} stdin_text - The string to pipe to stdin.
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
+ */
8
+ function execFileWithInput(interpreter, stdin_text, options) {
9
+ // FYI for now, using `exec()` so the interpreter can have cmd+args AIO
10
+ // could switch to `execFile()` to pass args array separately
11
+ // TODO starts with fish too? "fish -..." PRN use a library to parse the command and determine this?
12
+ if (interpreter.split(" ")[0] === "fish") {
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);
15
+ }
16
+ return new Promise((resolve, reject) => {
17
+ const child = exec(interpreter, options, (error, stdout, stderr) => {
18
+ if (error) {
19
+ reject({ message: error.message, stdout, stderr });
20
+ }
21
+ else {
22
+ resolve({ stdout, stderr });
23
+ }
24
+ });
25
+ if (stdin_text) {
26
+ if (child.stdin === null) {
27
+ reject(new Error("Unexpected failure: child.stdin is null"));
28
+ return;
29
+ }
30
+ child.stdin.write(stdin_text);
31
+ child.stdin.end();
32
+ }
33
+ });
34
+ }
35
+ async function fishWorkaround(interpreter, script, options) {
36
+ // fish right now chokes on piped input (STDIN) + node's exec/spawn/etc, so lets use a workaround to echo the input
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"`;
40
+ return new Promise((resolve, reject) => {
41
+ // const child = ... // careful with refactoring not to return that unused child
42
+ exec(command, options, (error, stdout, stderr) => {
43
+ // I like this style of error vs success handling! it's beautiful-est (prommises are underrated)
44
+ if (error) {
45
+ reject({ message: error.message, stdout, stderr });
46
+ }
47
+ else {
48
+ resolve({ stdout, stderr });
49
+ }
50
+ });
51
+ });
52
+ }
53
+ export { execFileWithInput };
package/build/index.js CHANGED
@@ -4,10 +4,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { exec } from "node:child_process";
6
6
  import { promisify } from "node:util";
7
+ import { execFileWithInput } from "./exec-utils.js";
8
+ // TODO use .promises?
7
9
  const execAsync = promisify(exec);
8
10
  const server = new Server({
9
11
  name: "mcp-server-commands",
10
- version: "0.2.0",
12
+ version: "0.3.0",
11
13
  }, {
12
14
  capabilities: {
13
15
  //resources: {},
@@ -25,74 +27,152 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
25
27
  properties: {
26
28
  command: {
27
29
  type: "string",
28
- description: "Command to run",
30
+ description: "Command with args",
29
31
  },
32
+ cwd: {
33
+ // previous run_command calls can probe the filesystem and find paths to change to
34
+ type: "string",
35
+ description: "Current working directory, leave empty in most cases",
36
+ },
37
+ // 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?
38
+ // FYI gonna use execFile for run_script
39
+ // - env - obscure cases where command takes a param only via an env var?
40
+ // args to consider:
41
+ // - timeout - lets just hard code this for now
42
+ // - shell - (cmd/args) - for now use run_script for this case, also can just pass "fish -c 'command'" or "sh ..."
43
+ // - stdin? though this borders on the run_script below
44
+ // - capture_output (default true) - for now can just redirect to /dev/null - perhaps capture_stdout/capture_stderr
30
45
  },
31
46
  required: ["command"],
32
47
  },
33
48
  },
49
+ // 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)
50
+ {
51
+ // TODO is run_script even needed if I were to add STDIN support to run_command above?
52
+ name: "run_script",
53
+ inputSchema: {
54
+ type: "object",
55
+ properties: {
56
+ interpreter: {
57
+ // TODO use shebang on *nix?
58
+ type: "string",
59
+ description: "Command with arguments. Script will be piped to stdin. Examples: bash, fish, zsh, python, or: bash --norc",
60
+ },
61
+ script: {
62
+ type: "string",
63
+ description: "Script to run",
64
+ },
65
+ cwd: {
66
+ type: "string",
67
+ description: "Current working directory",
68
+ },
69
+ },
70
+ required: ["script"],
71
+ },
72
+ },
34
73
  ],
35
74
  };
36
75
  });
37
76
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
38
77
  switch (request.params.name) {
39
78
  case "run_command": {
40
- const command = String(request.params.arguments?.command);
41
- if (!command) {
42
- throw new Error("Command is required");
43
- }
44
- try {
45
- const { stdout, stderr } = await execAsync(command);
46
- return {
47
- toolResult: {
48
- isError: false,
49
- content: [
50
- {
51
- type: "text",
52
- text: stdout,
53
- name: "STDOUT",
54
- },
55
- {
56
- type: "text",
57
- text: stderr,
58
- name: "STDERR",
59
- },
60
- ],
61
- },
62
- };
63
- }
64
- catch (error) {
65
- const { message, stdout, stderr } = error;
66
- return {
67
- toolResult: {
68
- isError: true,
69
- content: [
70
- {
71
- // most of the time this is gonna match stderr, TODO do I want/need both error and stderr?
72
- type: "text",
73
- text: message,
74
- name: "ERROR",
75
- },
76
- {
77
- type: "text",
78
- text: stderr || "",
79
- name: "STDERR",
80
- },
81
- {
82
- // keep STDOUT b/c there might be some useful output before the failure
83
- type: "text",
84
- text: stdout || "",
85
- name: "STDOUT",
86
- },
87
- ],
88
- },
89
- };
90
- }
79
+ return {
80
+ toolResult: await runCommand(request.params.arguments),
81
+ };
82
+ }
83
+ case "run_script": {
84
+ return {
85
+ toolResult: await runScript(request.params.arguments),
86
+ };
91
87
  }
92
88
  default:
93
89
  throw new Error("Unknown tool");
94
90
  }
95
91
  });
92
+ async function runCommand(args) {
93
+ const command = String(args?.command);
94
+ if (!command) {
95
+ throw new Error("Command is required");
96
+ }
97
+ const options = {};
98
+ if (args?.cwd) {
99
+ options.cwd = String(args.cwd);
100
+ // ENOENT is thrown if the cwd doesn't exist, and I think LLMs can understand that?
101
+ }
102
+ try {
103
+ const result = await execAsync(command, options);
104
+ return {
105
+ isError: false,
106
+ content: messagesFor(result),
107
+ };
108
+ }
109
+ catch (error) {
110
+ // TODO catch for other errors, not just ExecException
111
+ return {
112
+ isError: true,
113
+ content: messagesFor(error),
114
+ //content: [{ type: "text", text: JSON.stringify(error) }],
115
+ };
116
+ }
117
+ }
118
+ async function runScript(args) {
119
+ const interpreter = String(args?.interpreter);
120
+ if (!interpreter) {
121
+ throw new Error("Interpreter is required");
122
+ }
123
+ const options = {
124
+ //const options = {
125
+ // constrains typescript too, to string based overload
126
+ encoding: "utf8",
127
+ };
128
+ if (args?.cwd) {
129
+ options.cwd = String(args.cwd);
130
+ // ENOENT is thrown if the cwd doesn't exist, and I think LLMs can understand that?
131
+ }
132
+ const script = String(args?.script);
133
+ if (!script) {
134
+ throw new Error("Script is required");
135
+ }
136
+ try {
137
+ const result = await execFileWithInput(interpreter, script, options);
138
+ return {
139
+ isError: false,
140
+ content: messagesFor(result),
141
+ };
142
+ }
143
+ catch (error) {
144
+ return {
145
+ isError: true,
146
+ content: messagesFor(error),
147
+ };
148
+ }
149
+ }
150
+ function messagesFor(result) {
151
+ const messages = [];
152
+ if (result.message) {
153
+ messages.push({
154
+ // most of the time this is gonna match stderr, TODO do I want/need both error and stderr?
155
+ type: "text",
156
+ text: result.message,
157
+ name: "ERROR",
158
+ });
159
+ }
160
+ if (result.stdout) {
161
+ messages.push({
162
+ type: "text",
163
+ text: result.stdout,
164
+ name: "STDOUT",
165
+ });
166
+ }
167
+ if (result.stderr) {
168
+ messages.push({
169
+ type: "text",
170
+ text: result.stderr,
171
+ name: "STDERR",
172
+ });
173
+ }
174
+ return messages;
175
+ }
96
176
  server.setRequestHandler(ListPromptsRequestSchema, async () => {
97
177
  return {
98
178
  prompts: [
@@ -117,8 +197,12 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
117
197
  if (!command) {
118
198
  throw new Error("Command is required");
119
199
  }
200
+ // Is it possible/feasible to pass a path for the CWD when running the command?
201
+ // - currently it uses / (yikez)
202
+ // - IMO makes more sense to have it be based on the Zed CWD of each project
203
+ // - 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)
120
204
  const { stdout, stderr } = await execAsync(command);
121
- // let error bubble up, errors look good in zed /prompts (i.e. command not found)
205
+ // 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
122
206
  const messages = [
123
207
  {
124
208
  role: "user",
@@ -129,7 +213,7 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
129
213
  },
130
214
  },
131
215
  ];
132
- if (stdout && stdout.length > 0) {
216
+ if (stdout) {
133
217
  messages.push({
134
218
  role: "user",
135
219
  content: {
@@ -138,7 +222,7 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
138
222
  },
139
223
  });
140
224
  }
141
- if (stderr && stderr.length > 0) {
225
+ if (stderr) {
142
226
  messages.push({
143
227
  role: "user",
144
228
  content: {
@@ -150,7 +234,6 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
150
234
  return { messages };
151
235
  });
152
236
  async function main() {
153
- console.log("Starting server...");
154
237
  const transport = new StdioServerTransport();
155
238
  await server.connect(transport);
156
239
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-server-commands",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "An MCP server to run arbitrary commands",
5
5
  "private": false,
6
6
  "type": "module",
@@ -14,7 +14,7 @@
14
14
  "clean": "rm -rf build",
15
15
  "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
16
16
  "prepare": "npm run build",
17
- "watch": "tsc --watch",
17
+ "watch": "npm run build && tsc --watch",
18
18
  "inspector": "npx @modelcontextprotocol/inspector build/index.js"
19
19
  },
20
20
  "dependencies": {