bash-command-mcp 0.1.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/Dockerfile +13 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/index.js +57 -0
- package/dist/src/background-manager.js +263 -0
- package/dist/src/error-utils.js +14 -0
- package/dist/src/observability.js +261 -0
- package/dist/src/shell.js +70 -0
- package/dist/src/tools.js +348 -0
- package/package.json +71 -0
package/Dockerfile
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mrorigo@gmail.com
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# bash-command-mcp
|
|
2
|
+
|
|
3
|
+
A highly sophisticated Bash MCP server for safe, structured command execution with first-class background job orchestration.
|
|
4
|
+
|
|
5
|
+
## Important Security Warning
|
|
6
|
+
|
|
7
|
+
This server executes shell commands on the machine where it is running.
|
|
8
|
+
|
|
9
|
+
If you run `bun run index.ts` directly on your host, commands run on your host with your user permissions.
|
|
10
|
+
Use Docker to isolate execution unless you fully trust the MCP client and prompts.
|
|
11
|
+
|
|
12
|
+
To install dependencies:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bun install
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
To run:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
bun run index.ts
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
To run via npm/npx (published package):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx -y bash-command-mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Why This Server
|
|
31
|
+
|
|
32
|
+
- High-fidelity shell execution with clear exit-code semantics.
|
|
33
|
+
- Advanced background process lifecycle controls (`run_background`, `wait_background`, `kill_background`).
|
|
34
|
+
- Built-in observability via per-process stdout/stderr log files.
|
|
35
|
+
- OpenTelemetry traces and metrics for production visibility.
|
|
36
|
+
- Agent-friendly ergonomics with `cwd` and `env` overrides for precise execution context.
|
|
37
|
+
|
|
38
|
+
## Tool Behavior
|
|
39
|
+
|
|
40
|
+
Tools:
|
|
41
|
+
- `run`: run command in foreground.
|
|
42
|
+
Args: `command` or `cmd`, `timeoutSeconds` (default `60`, min `1`), optional `cwd`, optional `env`.
|
|
43
|
+
- `run_background`: start command in background with stdout/stderr written to log files.
|
|
44
|
+
Args: `command` or `cmd`, optional `cwd`, optional `env`.
|
|
45
|
+
- `list_background`: list tracked background processes, including log file paths.
|
|
46
|
+
- `kill_background`: stop tracked background process by `pid`.
|
|
47
|
+
- `tail_background`: show last N lines from background process logs.
|
|
48
|
+
Args: `pid`, optional `lines` (default `200`, max `5000`).
|
|
49
|
+
- `wait_background`: wait for background process completion and return final status/output.
|
|
50
|
+
Args: `pid`, optional `timeoutSeconds` (default `60`, min `1`).
|
|
51
|
+
## OpenTelemetry
|
|
52
|
+
|
|
53
|
+
This server includes optional OpenTelemetry instrumentation for traces and metrics.
|
|
54
|
+
|
|
55
|
+
- If OTel packages are available and `OTEL_ENABLED` is not set to `false`, telemetry is initialized.
|
|
56
|
+
- If `OTEL_EXPORTER_OTLP_ENDPOINT` is set, traces/metrics are exported via OTLP HTTP.
|
|
57
|
+
- If no OTLP endpoint is configured, console exporters are used.
|
|
58
|
+
- If OTel packages are not installed, the server continues normally with telemetry disabled.
|
|
59
|
+
|
|
60
|
+
Instrumented operations:
|
|
61
|
+
- Tool call spans for `run`, `run_background`, `list_background`, `tail_background`, `wait_background`, and `kill_background`.
|
|
62
|
+
- Background lifecycle spans/counters (`started`, `ended`).
|
|
63
|
+
- Metrics for tool calls, failures, timeouts, and duration histograms.
|
|
64
|
+
|
|
65
|
+
Common env vars:
|
|
66
|
+
- `OTEL_ENABLED=true|false`
|
|
67
|
+
- `OTEL_SERVICE_NAME=bash-command-mcp`
|
|
68
|
+
- `OTEL_SERVICE_VERSION=1.0.0`
|
|
69
|
+
- `OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318`
|
|
70
|
+
- `OTEL_METRIC_EXPORT_INTERVAL_MS=10000`
|
|
71
|
+
- `BASH_COMMAND_MCP_LOG_DIR=/path/to/log-dir`
|
|
72
|
+
|
|
73
|
+
Example (OTLP Collector on localhost):
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
OTEL_ENABLED=true \
|
|
77
|
+
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
|
|
78
|
+
OTEL_SERVICE_NAME=bash-command-mcp \
|
|
79
|
+
npx -y bash-command-mcp
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Docker
|
|
83
|
+
|
|
84
|
+
Build the image:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
docker build -t bash-command-mcp .
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Run with a local folder mounted at `/workspace`:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
docker run --rm -i -v "$(pwd):/workspace" bash-command-mcp
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`/workspace` mapping explained:
|
|
97
|
+
- Left side (`$(pwd)`) is a folder on your host machine.
|
|
98
|
+
- Right side (`/workspace`) is the path inside the container.
|
|
99
|
+
- Commands run by this MCP server should target files under `/workspace`; those changes are written back to the mapped host folder.
|
|
100
|
+
|
|
101
|
+
Example:
|
|
102
|
+
- If your host has `./project/file.txt` and you run the container from `./project`, the same file is available in the container at `/workspace/file.txt`.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { BackgroundProcessManager, } from "./src/background-manager.js";
|
|
5
|
+
import { Observability } from "./src/observability.js";
|
|
6
|
+
import { registerTools } from "./src/tools.js";
|
|
7
|
+
const server = new McpServer({
|
|
8
|
+
name: "bash-command-mcp",
|
|
9
|
+
version: "1.0.0",
|
|
10
|
+
});
|
|
11
|
+
const observability = new Observability();
|
|
12
|
+
function onBackgroundLifecycle(event, view) {
|
|
13
|
+
if (event === "started") {
|
|
14
|
+
observability.backgroundEvent("started", {
|
|
15
|
+
"mcp.command.pid": view.pid,
|
|
16
|
+
"mcp.command.status": view.status,
|
|
17
|
+
"mcp.command.cwd": view.cwd,
|
|
18
|
+
});
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
observability.backgroundEvent("ended", {
|
|
22
|
+
"mcp.command.pid": view.pid,
|
|
23
|
+
"mcp.command.status": view.status,
|
|
24
|
+
"mcp.command.exit_code": view.exitCode ?? -1,
|
|
25
|
+
"mcp.command.signal": view.signal ?? "none",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const backgroundManager = new BackgroundProcessManager(process.env.BASH_COMMAND_MCP_LOG_DIR, onBackgroundLifecycle);
|
|
29
|
+
registerTools(server, backgroundManager, observability);
|
|
30
|
+
async function shutdownWithCode(exitCode) {
|
|
31
|
+
try {
|
|
32
|
+
await observability.shutdown();
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
console.error("Observability shutdown error:", error);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
process.exit(exitCode);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function main() {
|
|
42
|
+
await observability.init();
|
|
43
|
+
const transport = new StdioServerTransport();
|
|
44
|
+
await server.connect(transport);
|
|
45
|
+
const status = observability.getStatus();
|
|
46
|
+
console.error(`bash-command-mcp running on stdio (observability=${status.enabled ? "on" : "off"}, reason=${status.reason})`);
|
|
47
|
+
process.on("SIGINT", () => {
|
|
48
|
+
void shutdownWithCode(0);
|
|
49
|
+
});
|
|
50
|
+
process.on("SIGTERM", () => {
|
|
51
|
+
void shutdownWithCode(0);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
main().catch((error) => {
|
|
55
|
+
console.error("Fatal error in main():", error);
|
|
56
|
+
void shutdownWithCode(1);
|
|
57
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { closeSync, existsSync, mkdirSync, openSync, statSync } from "node:fs";
|
|
2
|
+
import { readFile, open } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { terminateProcess } from "./shell.js";
|
|
7
|
+
import { getErrorMessage } from "./error-utils.js";
|
|
8
|
+
function defaultLogDir() {
|
|
9
|
+
return (process.env.BASH_COMMAND_MCP_LOG_DIR || join(tmpdir(), "bash-command-mcp"));
|
|
10
|
+
}
|
|
11
|
+
async function readTail(filePath, lines) {
|
|
12
|
+
const maxBytes = 256 * 1024;
|
|
13
|
+
if (!existsSync(filePath)) {
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
const fileSize = statSync(filePath).size;
|
|
17
|
+
if (fileSize === 0) {
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
const start = Math.max(0, fileSize - maxBytes);
|
|
21
|
+
const length = fileSize - start;
|
|
22
|
+
const file = await open(filePath, "r");
|
|
23
|
+
try {
|
|
24
|
+
const buffer = Buffer.alloc(length);
|
|
25
|
+
await file.read(buffer, 0, length, start);
|
|
26
|
+
let text = buffer.toString("utf8");
|
|
27
|
+
if (start > 0) {
|
|
28
|
+
const firstNewline = text.indexOf("\n");
|
|
29
|
+
if (firstNewline >= 0) {
|
|
30
|
+
text = text.slice(firstNewline + 1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const split = text.split(/\r?\n/);
|
|
34
|
+
const trimmed = split.at(-1) === "" ? split.slice(0, -1) : split;
|
|
35
|
+
return trimmed.slice(-lines).join("\n");
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await file.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function toView(record) {
|
|
42
|
+
return {
|
|
43
|
+
pid: record.pid,
|
|
44
|
+
command: record.command,
|
|
45
|
+
cwd: record.cwd,
|
|
46
|
+
startedAt: record.startedAt,
|
|
47
|
+
status: record.status,
|
|
48
|
+
exitCode: record.exitCode,
|
|
49
|
+
signal: record.signal,
|
|
50
|
+
endedAt: record.endedAt,
|
|
51
|
+
error: record.error,
|
|
52
|
+
stdoutLogPath: record.stdoutLogPath,
|
|
53
|
+
stderrLogPath: record.stderrLogPath,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export class BackgroundProcessManager {
|
|
57
|
+
records = new Map();
|
|
58
|
+
logDir;
|
|
59
|
+
onLifecycleEvent;
|
|
60
|
+
constructor(logDir = defaultLogDir(), onLifecycleEvent) {
|
|
61
|
+
this.logDir = logDir;
|
|
62
|
+
this.onLifecycleEvent = onLifecycleEvent;
|
|
63
|
+
mkdirSync(this.logDir, { recursive: true });
|
|
64
|
+
}
|
|
65
|
+
start(command, overrides) {
|
|
66
|
+
const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
67
|
+
const stdoutLogPath = join(this.logDir, `bg-${unique}.stdout.log`);
|
|
68
|
+
const stderrLogPath = join(this.logDir, `bg-${unique}.stderr.log`);
|
|
69
|
+
const stdoutFd = openSync(stdoutLogPath, "a");
|
|
70
|
+
const stderrFd = openSync(stderrLogPath, "a");
|
|
71
|
+
try {
|
|
72
|
+
const child = spawn(command, {
|
|
73
|
+
shell: true,
|
|
74
|
+
detached: true,
|
|
75
|
+
cwd: overrides.cwd,
|
|
76
|
+
env: overrides.env ? { ...process.env, ...overrides.env } : process.env,
|
|
77
|
+
stdio: ["ignore", stdoutFd, stderrFd],
|
|
78
|
+
});
|
|
79
|
+
child.unref();
|
|
80
|
+
if (!child.pid) {
|
|
81
|
+
throw new Error("failed to start process (missing pid)");
|
|
82
|
+
}
|
|
83
|
+
let resolveCompletion = () => { };
|
|
84
|
+
const completion = new Promise((resolve) => {
|
|
85
|
+
resolveCompletion = resolve;
|
|
86
|
+
});
|
|
87
|
+
const record = {
|
|
88
|
+
pid: child.pid,
|
|
89
|
+
command,
|
|
90
|
+
cwd: overrides.cwd || process.cwd(),
|
|
91
|
+
startedAt: new Date().toISOString(),
|
|
92
|
+
status: "running",
|
|
93
|
+
exitCode: null,
|
|
94
|
+
signal: null,
|
|
95
|
+
endedAt: null,
|
|
96
|
+
error: null,
|
|
97
|
+
stdoutLogPath,
|
|
98
|
+
stderrLogPath,
|
|
99
|
+
child,
|
|
100
|
+
completion,
|
|
101
|
+
resolveCompletion,
|
|
102
|
+
completed: false,
|
|
103
|
+
};
|
|
104
|
+
this.records.set(record.pid, record);
|
|
105
|
+
this.onLifecycleEvent?.("started", toView(record));
|
|
106
|
+
child.on("close", (code, signal) => {
|
|
107
|
+
const current = this.records.get(record.pid);
|
|
108
|
+
if (!current) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (current.completed) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
current.exitCode = typeof code === "number" ? code : null;
|
|
115
|
+
current.signal = signal ?? null;
|
|
116
|
+
current.endedAt = new Date().toISOString();
|
|
117
|
+
if (current.status === "running") {
|
|
118
|
+
current.status = signal ? "killed" : "exited";
|
|
119
|
+
}
|
|
120
|
+
current.completed = true;
|
|
121
|
+
this.onLifecycleEvent?.(signal ? "killed" : "completed", toView(current));
|
|
122
|
+
current.resolveCompletion(toView(current));
|
|
123
|
+
});
|
|
124
|
+
child.on("error", (error) => {
|
|
125
|
+
const current = this.records.get(record.pid);
|
|
126
|
+
if (!current || current.completed) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
current.status = "error";
|
|
130
|
+
current.error = error.message;
|
|
131
|
+
current.endedAt = new Date().toISOString();
|
|
132
|
+
current.completed = true;
|
|
133
|
+
this.onLifecycleEvent?.("error", toView(current));
|
|
134
|
+
current.resolveCompletion(toView(current));
|
|
135
|
+
});
|
|
136
|
+
return toView(record);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
closeSync(stdoutFd);
|
|
140
|
+
closeSync(stderrFd);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
list() {
|
|
144
|
+
return Array.from(this.records.values())
|
|
145
|
+
.sort((a, b) => a.pid - b.pid)
|
|
146
|
+
.map((record) => toView(record));
|
|
147
|
+
}
|
|
148
|
+
get(pid) {
|
|
149
|
+
const record = this.records.get(pid);
|
|
150
|
+
return record ? toView(record) : null;
|
|
151
|
+
}
|
|
152
|
+
countRunning() {
|
|
153
|
+
return Array.from(this.records.values()).filter((record) => record.status === "running").length;
|
|
154
|
+
}
|
|
155
|
+
kill(pid) {
|
|
156
|
+
const record = this.records.get(pid);
|
|
157
|
+
if (!record) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
message: `No tracked background process found for PID ${pid}.`,
|
|
161
|
+
view: null,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (record.status !== "running") {
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
message: `Process ${pid} is already ${record.status}.`,
|
|
168
|
+
view: toView(record),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
terminateProcess(pid);
|
|
173
|
+
record.status = "killed";
|
|
174
|
+
record.signal = "SIGTERM";
|
|
175
|
+
record.endedAt = new Date().toISOString();
|
|
176
|
+
this.onLifecycleEvent?.("killed", toView(record));
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
message: `Sent SIGTERM to background process ${pid}.`,
|
|
180
|
+
view: toView(record),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
message: `Failed to kill process ${pid}: ${getErrorMessage(error)}`,
|
|
187
|
+
view: toView(record),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async tail(pid, lines) {
|
|
192
|
+
const record = this.records.get(pid);
|
|
193
|
+
if (!record) {
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
message: `No tracked background process found for PID ${pid}.`,
|
|
197
|
+
view: null,
|
|
198
|
+
stdout: "",
|
|
199
|
+
stderr: "",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const stdout = await readTail(record.stdoutLogPath, lines);
|
|
203
|
+
const stderr = await readTail(record.stderrLogPath, lines);
|
|
204
|
+
return {
|
|
205
|
+
ok: true,
|
|
206
|
+
message: `Showing last ${lines} line(s) for PID ${pid}.`,
|
|
207
|
+
view: toView(record),
|
|
208
|
+
stdout,
|
|
209
|
+
stderr,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
async wait(pid, timeoutSeconds) {
|
|
213
|
+
const record = this.records.get(pid);
|
|
214
|
+
if (!record) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
timedOut: false,
|
|
218
|
+
message: `No tracked background process found for PID ${pid}.`,
|
|
219
|
+
view: null,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (record.status !== "running") {
|
|
223
|
+
return {
|
|
224
|
+
ok: true,
|
|
225
|
+
timedOut: false,
|
|
226
|
+
message: `Process ${pid} is already ${record.status}.`,
|
|
227
|
+
view: toView(record),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
231
|
+
const timeoutResult = await Promise.race([
|
|
232
|
+
record.completion.then((view) => ({ type: "done", view })),
|
|
233
|
+
new Promise((resolve) => {
|
|
234
|
+
setTimeout(() => resolve({ type: "timeout" }), timeoutMs);
|
|
235
|
+
}),
|
|
236
|
+
]);
|
|
237
|
+
if (timeoutResult.type === "timeout") {
|
|
238
|
+
return {
|
|
239
|
+
ok: false,
|
|
240
|
+
timedOut: true,
|
|
241
|
+
message: `Timed out waiting for PID ${pid} after ${timeoutSeconds} second(s).`,
|
|
242
|
+
view: toView(record),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
ok: true,
|
|
247
|
+
timedOut: false,
|
|
248
|
+
message: `Process ${pid} completed with status ${timeoutResult.view.status}.`,
|
|
249
|
+
view: timeoutResult.view,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
async readLogs(pid) {
|
|
253
|
+
const record = this.records.get(pid);
|
|
254
|
+
if (!record) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
const [stdout, stderr] = await Promise.all([
|
|
258
|
+
readFile(record.stdoutLogPath, "utf8").catch(() => ""),
|
|
259
|
+
readFile(record.stderrLogPath, "utf8").catch(() => ""),
|
|
260
|
+
]);
|
|
261
|
+
return { stdout, stderr };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { getErrorMessage } from "./error-utils.js";
|
|
3
|
+
function sanitizeAttributes(attrs) {
|
|
4
|
+
const out = {};
|
|
5
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
6
|
+
if (value === undefined)
|
|
7
|
+
continue;
|
|
8
|
+
out[key] = value;
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
function assertObject(value, context) {
|
|
13
|
+
if (!value || typeof value !== "object") {
|
|
14
|
+
throw new Error(`${context}: expected object`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function asCallable(value, context) {
|
|
19
|
+
if (typeof value !== "function") {
|
|
20
|
+
throw new Error(`${context}: expected function`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function asConstructor(value, context) {
|
|
25
|
+
if (typeof value !== "function") {
|
|
26
|
+
throw new Error(`${context}: expected constructor`);
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function asTracerLike(value) {
|
|
31
|
+
const obj = assertObject(value, "tracer");
|
|
32
|
+
return {
|
|
33
|
+
startSpan: asCallable(obj.startSpan, "tracer.startSpan"),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function asMeterLike(value) {
|
|
37
|
+
const obj = assertObject(value, "meter");
|
|
38
|
+
return {
|
|
39
|
+
createCounter: asCallable(obj.createCounter, "meter.createCounter"),
|
|
40
|
+
createHistogram: asCallable(obj.createHistogram, "meter.createHistogram"),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
class NoopToolSpan {
|
|
44
|
+
setAttribute() { }
|
|
45
|
+
addEvent() { }
|
|
46
|
+
recordException() { }
|
|
47
|
+
end() { }
|
|
48
|
+
}
|
|
49
|
+
export class Observability {
|
|
50
|
+
tracer = null;
|
|
51
|
+
sdk = null;
|
|
52
|
+
spanStatusCodeError = null;
|
|
53
|
+
status;
|
|
54
|
+
toolCalls = null;
|
|
55
|
+
toolFailures = null;
|
|
56
|
+
toolTimeouts = null;
|
|
57
|
+
toolDuration = null;
|
|
58
|
+
bgStarted = null;
|
|
59
|
+
bgEnded = null;
|
|
60
|
+
constructor() {
|
|
61
|
+
this.status = {
|
|
62
|
+
enabled: false,
|
|
63
|
+
reason: "not_initialized",
|
|
64
|
+
serviceName: process.env.OTEL_SERVICE_NAME || "bash-command-mcp",
|
|
65
|
+
exporter: "none",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
async init() {
|
|
69
|
+
if (process.env.OTEL_ENABLED === "false") {
|
|
70
|
+
this.status = {
|
|
71
|
+
...this.status,
|
|
72
|
+
enabled: false,
|
|
73
|
+
reason: "disabled_by_env",
|
|
74
|
+
};
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const requireFn = createRequire(import.meta.url);
|
|
78
|
+
try {
|
|
79
|
+
const otelApiUnknown = requireFn("@opentelemetry/api");
|
|
80
|
+
const sdkNodeUnknown = requireFn("@opentelemetry/sdk-node");
|
|
81
|
+
const resourcesUnknown = requireFn("@opentelemetry/resources");
|
|
82
|
+
const otelApiObj = assertObject(otelApiUnknown, "@opentelemetry/api");
|
|
83
|
+
const sdkNodeObj = assertObject(sdkNodeUnknown, "@opentelemetry/sdk-node");
|
|
84
|
+
const resourcesObj = assertObject(resourcesUnknown, "@opentelemetry/resources");
|
|
85
|
+
const traceObj = assertObject(otelApiObj.trace, "otelApi.trace");
|
|
86
|
+
const metricsObj = assertObject(otelApiObj.metrics, "otelApi.metrics");
|
|
87
|
+
const spanStatusObj = assertObject(otelApiObj.SpanStatusCode, "otelApi.SpanStatusCode");
|
|
88
|
+
const getTracer = asCallable(traceObj.getTracer, "otelApi.trace.getTracer");
|
|
89
|
+
const getMeter = asCallable(metricsObj.getMeter, "otelApi.metrics.getMeter");
|
|
90
|
+
const otelApi = {
|
|
91
|
+
trace: {
|
|
92
|
+
getTracer: (name, version) => asTracerLike(getTracer(name, version)),
|
|
93
|
+
},
|
|
94
|
+
metrics: {
|
|
95
|
+
getMeter: (name, version) => asMeterLike(getMeter(name, version)),
|
|
96
|
+
},
|
|
97
|
+
SpanStatusCode: {
|
|
98
|
+
ERROR: typeof spanStatusObj.ERROR === "number" ? spanStatusObj.ERROR : 2,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const nodeSdkCtor = asConstructor(sdkNodeObj.NodeSDK, "NodeSDK");
|
|
102
|
+
const resourceCtor = asConstructor(resourcesObj.Resource, "Resource");
|
|
103
|
+
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
104
|
+
let traceExporter;
|
|
105
|
+
let metricReader;
|
|
106
|
+
let exporterName = "console";
|
|
107
|
+
if (endpoint) {
|
|
108
|
+
const traceHttpUnknown = requireFn("@opentelemetry/exporter-trace-otlp-http");
|
|
109
|
+
const metricHttpUnknown = requireFn("@opentelemetry/exporter-metrics-otlp-http");
|
|
110
|
+
const metricsSdkUnknown = requireFn("@opentelemetry/sdk-metrics");
|
|
111
|
+
const traceHttpObj = assertObject(traceHttpUnknown, "trace exporter module");
|
|
112
|
+
const metricHttpObj = assertObject(metricHttpUnknown, "metric exporter module");
|
|
113
|
+
const metricsSdkObj = assertObject(metricsSdkUnknown, "metrics sdk module");
|
|
114
|
+
const otlpTraceCtor = asConstructor(traceHttpObj.OTLPTraceExporter, "OTLPTraceExporter");
|
|
115
|
+
const otlpMetricCtor = asConstructor(metricHttpObj.OTLPMetricExporter, "OTLPMetricExporter");
|
|
116
|
+
const periodicReaderCtor = asConstructor(metricsSdkObj.PeriodicExportingMetricReader, "PeriodicExportingMetricReader");
|
|
117
|
+
const baseEndpoint = endpoint.replace(/\/$/, "");
|
|
118
|
+
traceExporter = new otlpTraceCtor({
|
|
119
|
+
url: `${baseEndpoint}/v1/traces`,
|
|
120
|
+
});
|
|
121
|
+
const metricExporter = new otlpMetricCtor({
|
|
122
|
+
url: `${baseEndpoint}/v1/metrics`,
|
|
123
|
+
});
|
|
124
|
+
metricReader = new periodicReaderCtor({
|
|
125
|
+
exporter: metricExporter,
|
|
126
|
+
exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL_MS || 10000),
|
|
127
|
+
});
|
|
128
|
+
exporterName = "otlp_http";
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
const traceBaseUnknown = requireFn("@opentelemetry/sdk-trace-base");
|
|
132
|
+
const metricsSdkUnknown = requireFn("@opentelemetry/sdk-metrics");
|
|
133
|
+
const traceBaseObj = assertObject(traceBaseUnknown, "trace base module");
|
|
134
|
+
const metricsSdkObj = assertObject(metricsSdkUnknown, "metrics sdk module");
|
|
135
|
+
const consoleSpanCtor = asConstructor(traceBaseObj.ConsoleSpanExporter, "ConsoleSpanExporter");
|
|
136
|
+
const periodicReaderCtor = asConstructor(metricsSdkObj.PeriodicExportingMetricReader, "PeriodicExportingMetricReader");
|
|
137
|
+
const consoleMetricCtor = asConstructor(metricsSdkObj.ConsoleMetricExporter, "ConsoleMetricExporter");
|
|
138
|
+
traceExporter = new consoleSpanCtor();
|
|
139
|
+
metricReader = new periodicReaderCtor({
|
|
140
|
+
exporter: new consoleMetricCtor(),
|
|
141
|
+
exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL_MS || 15000),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const serviceName = process.env.OTEL_SERVICE_NAME || "bash-command-mcp";
|
|
145
|
+
const serviceVersion = process.env.OTEL_SERVICE_VERSION || "1.0.0";
|
|
146
|
+
const resource = new resourceCtor({
|
|
147
|
+
"service.name": serviceName,
|
|
148
|
+
"service.version": serviceVersion,
|
|
149
|
+
});
|
|
150
|
+
this.sdk = new nodeSdkCtor({
|
|
151
|
+
resource,
|
|
152
|
+
traceExporter,
|
|
153
|
+
metricReader,
|
|
154
|
+
});
|
|
155
|
+
await this.sdk.start();
|
|
156
|
+
this.tracer = otelApi.trace.getTracer(serviceName, serviceVersion);
|
|
157
|
+
const meter = otelApi.metrics.getMeter(serviceName, serviceVersion);
|
|
158
|
+
this.spanStatusCodeError = otelApi.SpanStatusCode.ERROR;
|
|
159
|
+
this.toolCalls = meter.createCounter("mcp_tool_calls_total", {
|
|
160
|
+
description: "Total MCP tool invocations",
|
|
161
|
+
});
|
|
162
|
+
this.toolFailures = meter.createCounter("mcp_tool_failures_total", {
|
|
163
|
+
description: "Total failed MCP tool invocations",
|
|
164
|
+
});
|
|
165
|
+
this.toolTimeouts = meter.createCounter("mcp_tool_timeouts_total", {
|
|
166
|
+
description: "Total timed out MCP tool invocations",
|
|
167
|
+
});
|
|
168
|
+
this.toolDuration = meter.createHistogram("mcp_tool_duration_ms", {
|
|
169
|
+
description: "MCP tool execution duration",
|
|
170
|
+
unit: "ms",
|
|
171
|
+
});
|
|
172
|
+
this.bgStarted = meter.createCounter("mcp_background_started_total", {
|
|
173
|
+
description: "Total started background processes",
|
|
174
|
+
});
|
|
175
|
+
this.bgEnded = meter.createCounter("mcp_background_ended_total", {
|
|
176
|
+
description: "Total ended background processes",
|
|
177
|
+
});
|
|
178
|
+
this.status = {
|
|
179
|
+
enabled: true,
|
|
180
|
+
reason: "active",
|
|
181
|
+
serviceName,
|
|
182
|
+
exporter: exporterName,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
this.status = {
|
|
187
|
+
enabled: false,
|
|
188
|
+
reason: `otel_unavailable: ${getErrorMessage(error)}`,
|
|
189
|
+
serviceName: process.env.OTEL_SERVICE_NAME || "bash-command-mcp",
|
|
190
|
+
exporter: "none",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
getStatus() {
|
|
195
|
+
return this.status;
|
|
196
|
+
}
|
|
197
|
+
toolSpan(name, attrs = {}) {
|
|
198
|
+
if (!this.tracer) {
|
|
199
|
+
return new NoopToolSpan();
|
|
200
|
+
}
|
|
201
|
+
const startedAt = process.hrtime.bigint();
|
|
202
|
+
const span = this.tracer.startSpan(`mcp.tool.${name}`, {
|
|
203
|
+
attributes: sanitizeAttributes({
|
|
204
|
+
"mcp.tool.name": name,
|
|
205
|
+
...attrs,
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
this.toolCalls?.add(1, { tool: name });
|
|
209
|
+
return {
|
|
210
|
+
setAttribute: (key, value) => span.setAttribute(key, value),
|
|
211
|
+
addEvent: (eventName, eventAttrs) => {
|
|
212
|
+
span.addEvent(eventName, sanitizeAttributes(eventAttrs || {}));
|
|
213
|
+
},
|
|
214
|
+
recordException: (error) => {
|
|
215
|
+
if (error instanceof Error) {
|
|
216
|
+
span.recordException(error);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
span.recordException(new Error(getErrorMessage(error)));
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
end: (ok, endAttrs = {}) => {
|
|
223
|
+
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
|
224
|
+
this.toolDuration?.record(durationMs, { tool: name, ok: String(ok) });
|
|
225
|
+
if (!ok) {
|
|
226
|
+
this.toolFailures?.add(1, { tool: name });
|
|
227
|
+
if (this.spanStatusCodeError !== null) {
|
|
228
|
+
span.setStatus({
|
|
229
|
+
code: this.spanStatusCodeError,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const finalAttrs = sanitizeAttributes(endAttrs);
|
|
234
|
+
for (const [k, v] of Object.entries(finalAttrs)) {
|
|
235
|
+
span.setAttribute(k, v);
|
|
236
|
+
}
|
|
237
|
+
span.end();
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
markTimeout(toolName) {
|
|
242
|
+
this.toolTimeouts?.add(1, { tool: toolName });
|
|
243
|
+
}
|
|
244
|
+
backgroundEvent(event, attrs = {}) {
|
|
245
|
+
const span = this.tracer?.startSpan(`mcp.background.${event}`, {
|
|
246
|
+
attributes: sanitizeAttributes(attrs),
|
|
247
|
+
});
|
|
248
|
+
if (event === "started") {
|
|
249
|
+
this.bgStarted?.add(1);
|
|
250
|
+
}
|
|
251
|
+
if (event === "ended") {
|
|
252
|
+
this.bgEnded?.add(1);
|
|
253
|
+
}
|
|
254
|
+
span?.end();
|
|
255
|
+
}
|
|
256
|
+
async shutdown() {
|
|
257
|
+
if (!this.sdk)
|
|
258
|
+
return;
|
|
259
|
+
await this.sdk.shutdown();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
function isWindows() {
|
|
3
|
+
return process.platform === "win32";
|
|
4
|
+
}
|
|
5
|
+
export function normalizeCommand(command, cmd) {
|
|
6
|
+
const normalized = (Array.isArray(command)
|
|
7
|
+
? command.join(" ")
|
|
8
|
+
: Array.isArray(cmd)
|
|
9
|
+
? cmd.join(" ")
|
|
10
|
+
: (command ?? cmd))?.trim();
|
|
11
|
+
return normalized || null;
|
|
12
|
+
}
|
|
13
|
+
export function terminateProcess(pid) {
|
|
14
|
+
if (!isWindows()) {
|
|
15
|
+
process.kill(-pid, "SIGTERM");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
process.kill(pid, "SIGTERM");
|
|
19
|
+
}
|
|
20
|
+
export async function runForegroundCommand(command, timeoutSeconds, overrides) {
|
|
21
|
+
return await new Promise((resolve) => {
|
|
22
|
+
const child = spawn(command, {
|
|
23
|
+
shell: true,
|
|
24
|
+
detached: !isWindows(),
|
|
25
|
+
cwd: overrides.cwd,
|
|
26
|
+
env: overrides.env ? { ...process.env, ...overrides.env } : process.env,
|
|
27
|
+
});
|
|
28
|
+
let stdout = "";
|
|
29
|
+
let stderr = "";
|
|
30
|
+
let timedOut = false;
|
|
31
|
+
const timer = setTimeout(() => {
|
|
32
|
+
timedOut = true;
|
|
33
|
+
try {
|
|
34
|
+
if (child.pid)
|
|
35
|
+
terminateProcess(child.pid);
|
|
36
|
+
else
|
|
37
|
+
child.kill("SIGTERM");
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
child.kill("SIGTERM");
|
|
41
|
+
}
|
|
42
|
+
}, timeoutSeconds * 1000);
|
|
43
|
+
child.stdout?.on("data", (data) => {
|
|
44
|
+
stdout += data.toString();
|
|
45
|
+
});
|
|
46
|
+
child.stderr?.on("data", (data) => {
|
|
47
|
+
stderr += data.toString();
|
|
48
|
+
});
|
|
49
|
+
child.on("error", (error) => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
resolve({
|
|
52
|
+
exitCode: null,
|
|
53
|
+
stdout,
|
|
54
|
+
stderr,
|
|
55
|
+
timedOut,
|
|
56
|
+
errorMessage: error.message,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
child.on("close", (code) => {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
resolve({
|
|
62
|
+
exitCode: typeof code === "number" ? code : null,
|
|
63
|
+
stdout,
|
|
64
|
+
stderr,
|
|
65
|
+
timedOut,
|
|
66
|
+
errorMessage: null,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { normalizeCommand, runForegroundCommand, } from "./shell.js";
|
|
3
|
+
import { getErrorMessage } from "./error-utils.js";
|
|
4
|
+
const commandSchema = z
|
|
5
|
+
.union([z.string(), z.array(z.string())])
|
|
6
|
+
.optional()
|
|
7
|
+
.describe("Command as a string or string array.");
|
|
8
|
+
const envSchema = z
|
|
9
|
+
.record(z.string(), z.string())
|
|
10
|
+
.optional()
|
|
11
|
+
.describe("Environment variable overrides for the command.");
|
|
12
|
+
function formatCommandResult(exitCode, stdout, stderr, fallback) {
|
|
13
|
+
let output = `EXIT_CODE: ${exitCode}\n`;
|
|
14
|
+
if (stdout)
|
|
15
|
+
output += `STDOUT:\n${stdout}\n`;
|
|
16
|
+
if (stderr)
|
|
17
|
+
output += `STDERR:\n${stderr}\n`;
|
|
18
|
+
return output.trim() || `EXIT_CODE: ${exitCode}\n${fallback}`;
|
|
19
|
+
}
|
|
20
|
+
function formatBackgroundView(view) {
|
|
21
|
+
return [
|
|
22
|
+
`PID: ${view.pid}`,
|
|
23
|
+
`STATUS: ${view.status}`,
|
|
24
|
+
`EXIT_CODE: ${view.exitCode ?? "N/A"}`,
|
|
25
|
+
`SIGNAL: ${view.signal ?? "N/A"}`,
|
|
26
|
+
`STARTED_AT: ${view.startedAt}`,
|
|
27
|
+
`ENDED_AT: ${view.endedAt ?? "N/A"}`,
|
|
28
|
+
`CWD: ${view.cwd}`,
|
|
29
|
+
`STDOUT_LOG: ${view.stdoutLogPath}`,
|
|
30
|
+
`STDERR_LOG: ${view.stderrLogPath}`,
|
|
31
|
+
`COMMAND: ${view.command}`,
|
|
32
|
+
view.error ? `ERROR: ${view.error}` : null,
|
|
33
|
+
]
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.join("\n");
|
|
36
|
+
}
|
|
37
|
+
function resolveOverrides(cwd, env) {
|
|
38
|
+
return { cwd, env };
|
|
39
|
+
}
|
|
40
|
+
export function registerTools(server, backgroundManager, observability) {
|
|
41
|
+
server.registerTool("run", {
|
|
42
|
+
description: "Run a shell command in the foreground and return stdout/stderr with exit code.",
|
|
43
|
+
inputSchema: {
|
|
44
|
+
command: commandSchema,
|
|
45
|
+
cmd: commandSchema,
|
|
46
|
+
timeoutSeconds: z
|
|
47
|
+
.number()
|
|
48
|
+
.int()
|
|
49
|
+
.min(1)
|
|
50
|
+
.max(24 * 3600)
|
|
51
|
+
.default(60)
|
|
52
|
+
.describe("Execution timeout in seconds. Default 60."),
|
|
53
|
+
cwd: z.string().optional().describe("Working directory override."),
|
|
54
|
+
env: envSchema,
|
|
55
|
+
},
|
|
56
|
+
}, async ({ command, cmd, timeoutSeconds, cwd, env }) => {
|
|
57
|
+
const span = observability.toolSpan("run", {
|
|
58
|
+
"mcp.command.cwd": cwd,
|
|
59
|
+
"mcp.command.timeout_seconds": timeoutSeconds,
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
const normalizedCommand = normalizeCommand(command, cmd);
|
|
63
|
+
if (!normalizedCommand) {
|
|
64
|
+
span.end(false, { "mcp.command.valid": false });
|
|
65
|
+
return {
|
|
66
|
+
content: [
|
|
67
|
+
{
|
|
68
|
+
type: "text",
|
|
69
|
+
text: 'EXIT_CODE: unknown\nEither "command" or "cmd" must be provided.',
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
isError: true,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
span.setAttribute("mcp.command.value", normalizedCommand);
|
|
76
|
+
const result = await runForegroundCommand(normalizedCommand, timeoutSeconds, resolveOverrides(cwd, env));
|
|
77
|
+
if (result.errorMessage) {
|
|
78
|
+
span.recordException(new Error(result.errorMessage));
|
|
79
|
+
span.end(false, {
|
|
80
|
+
"mcp.command.status": "spawn_error",
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
content: [
|
|
84
|
+
{
|
|
85
|
+
type: "text",
|
|
86
|
+
text: formatCommandResult("unknown", result.stdout, result.stderr, `Command failed with error:\n${result.errorMessage}`),
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
isError: true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const exitCode = typeof result.exitCode === "number"
|
|
93
|
+
? String(result.exitCode)
|
|
94
|
+
: "unknown";
|
|
95
|
+
const timedOutMessage = result.timedOut
|
|
96
|
+
? `Command timed out after ${timeoutSeconds} second(s).`
|
|
97
|
+
: "No output returned.";
|
|
98
|
+
if (result.timedOut) {
|
|
99
|
+
observability.markTimeout("run");
|
|
100
|
+
}
|
|
101
|
+
const ok = !result.timedOut && result.exitCode === 0;
|
|
102
|
+
span.end(ok, {
|
|
103
|
+
"mcp.command.exit_code": typeof result.exitCode === "number" ? result.exitCode : -1,
|
|
104
|
+
"mcp.command.timed_out": result.timedOut,
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
content: [
|
|
108
|
+
{
|
|
109
|
+
type: "text",
|
|
110
|
+
text: formatCommandResult(exitCode, result.stdout, result.stderr, timedOutMessage),
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
isError: !ok,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
span.recordException(error);
|
|
118
|
+
span.end(false, { "mcp.command.status": "exception" });
|
|
119
|
+
return {
|
|
120
|
+
content: [
|
|
121
|
+
{
|
|
122
|
+
type: "text",
|
|
123
|
+
text: `EXIT_CODE: unknown\nUnexpected error:\n${getErrorMessage(error)}`,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
isError: true,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
server.registerTool("run_background", {
|
|
131
|
+
description: "Start a shell command in the background and capture stdout/stderr to log files.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
command: commandSchema,
|
|
134
|
+
cmd: commandSchema,
|
|
135
|
+
cwd: z.string().optional().describe("Working directory override."),
|
|
136
|
+
env: envSchema,
|
|
137
|
+
},
|
|
138
|
+
}, async ({ command, cmd, cwd, env }) => {
|
|
139
|
+
const span = observability.toolSpan("run_background", {
|
|
140
|
+
"mcp.command.cwd": cwd,
|
|
141
|
+
});
|
|
142
|
+
try {
|
|
143
|
+
const normalizedCommand = normalizeCommand(command, cmd);
|
|
144
|
+
if (!normalizedCommand) {
|
|
145
|
+
span.end(false, { "mcp.command.valid": false });
|
|
146
|
+
return {
|
|
147
|
+
content: [
|
|
148
|
+
{
|
|
149
|
+
type: "text",
|
|
150
|
+
text: 'Either "command" or "cmd" must be provided.',
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
isError: true,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
span.setAttribute("mcp.command.value", normalizedCommand);
|
|
157
|
+
const started = backgroundManager.start(normalizedCommand, resolveOverrides(cwd, env));
|
|
158
|
+
span.end(true, {
|
|
159
|
+
"mcp.command.pid": started.pid,
|
|
160
|
+
});
|
|
161
|
+
return {
|
|
162
|
+
content: [
|
|
163
|
+
{
|
|
164
|
+
type: "text",
|
|
165
|
+
text: `Started background process.\n${formatBackgroundView(started)}`,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
span.recordException(error);
|
|
172
|
+
span.end(false);
|
|
173
|
+
return {
|
|
174
|
+
content: [
|
|
175
|
+
{
|
|
176
|
+
type: "text",
|
|
177
|
+
text: `Failed to start background process:\n${getErrorMessage(error)}`,
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
isError: true,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
server.registerTool("list_background", {
|
|
185
|
+
description: "List tracked background processes and their status/log paths.",
|
|
186
|
+
inputSchema: {},
|
|
187
|
+
}, async () => {
|
|
188
|
+
const span = observability.toolSpan("list_background");
|
|
189
|
+
const entries = backgroundManager.list();
|
|
190
|
+
span.end(true, {
|
|
191
|
+
"mcp.background.count": entries.length,
|
|
192
|
+
});
|
|
193
|
+
if (entries.length === 0) {
|
|
194
|
+
return {
|
|
195
|
+
content: [
|
|
196
|
+
{
|
|
197
|
+
type: "text",
|
|
198
|
+
text: "No tracked background processes.",
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
content: [
|
|
205
|
+
{
|
|
206
|
+
type: "text",
|
|
207
|
+
text: entries
|
|
208
|
+
.map((entry) => formatBackgroundView(entry))
|
|
209
|
+
.join("\n\n"),
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
server.registerTool("kill_background", {
|
|
215
|
+
description: "Stop a tracked background process by PID.",
|
|
216
|
+
inputSchema: {
|
|
217
|
+
pid: z
|
|
218
|
+
.number()
|
|
219
|
+
.int()
|
|
220
|
+
.positive()
|
|
221
|
+
.describe("PID of the process to stop."),
|
|
222
|
+
},
|
|
223
|
+
}, async ({ pid }) => {
|
|
224
|
+
const span = observability.toolSpan("kill_background", {
|
|
225
|
+
"mcp.command.pid": pid,
|
|
226
|
+
});
|
|
227
|
+
const result = backgroundManager.kill(pid);
|
|
228
|
+
span.end(result.ok, {
|
|
229
|
+
"mcp.command.status": result.ok ? "ok" : "error",
|
|
230
|
+
});
|
|
231
|
+
return {
|
|
232
|
+
content: [
|
|
233
|
+
{
|
|
234
|
+
type: "text",
|
|
235
|
+
text: result.view
|
|
236
|
+
? `${result.message}\n${formatBackgroundView(result.view)}`
|
|
237
|
+
: result.message,
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
isError: !result.ok,
|
|
241
|
+
};
|
|
242
|
+
});
|
|
243
|
+
server.registerTool("tail_background", {
|
|
244
|
+
description: "Read the last N lines from stdout/stderr logs for a tracked background process.",
|
|
245
|
+
inputSchema: {
|
|
246
|
+
pid: z.number().int().positive().describe("PID to inspect."),
|
|
247
|
+
lines: z
|
|
248
|
+
.number()
|
|
249
|
+
.int()
|
|
250
|
+
.min(1)
|
|
251
|
+
.max(5000)
|
|
252
|
+
.default(200)
|
|
253
|
+
.describe("Number of lines to read from the end of each log."),
|
|
254
|
+
},
|
|
255
|
+
}, async ({ pid, lines }) => {
|
|
256
|
+
const span = observability.toolSpan("tail_background", {
|
|
257
|
+
"mcp.command.pid": pid,
|
|
258
|
+
"mcp.command.tail_lines": lines,
|
|
259
|
+
});
|
|
260
|
+
const result = await backgroundManager.tail(pid, lines);
|
|
261
|
+
if (!result.ok || !result.view) {
|
|
262
|
+
span.end(false);
|
|
263
|
+
return {
|
|
264
|
+
content: [
|
|
265
|
+
{
|
|
266
|
+
type: "text",
|
|
267
|
+
text: result.message,
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
isError: true,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
span.end(true, {
|
|
274
|
+
"mcp.command.status": result.view.status,
|
|
275
|
+
});
|
|
276
|
+
return {
|
|
277
|
+
content: [
|
|
278
|
+
{
|
|
279
|
+
type: "text",
|
|
280
|
+
text: [
|
|
281
|
+
result.message,
|
|
282
|
+
formatBackgroundView(result.view),
|
|
283
|
+
`STDOUT_TAIL:\n${result.stdout || "<empty>"}`,
|
|
284
|
+
`STDERR_TAIL:\n${result.stderr || "<empty>"}`,
|
|
285
|
+
].join("\n\n"),
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
};
|
|
289
|
+
});
|
|
290
|
+
server.registerTool("wait_background", {
|
|
291
|
+
description: "Wait for a tracked background process to finish and return final status.",
|
|
292
|
+
inputSchema: {
|
|
293
|
+
pid: z.number().int().positive().describe("PID to wait on."),
|
|
294
|
+
timeoutSeconds: z
|
|
295
|
+
.number()
|
|
296
|
+
.int()
|
|
297
|
+
.min(1)
|
|
298
|
+
.max(24 * 3600)
|
|
299
|
+
.default(60)
|
|
300
|
+
.describe("How long to wait before returning timeout."),
|
|
301
|
+
},
|
|
302
|
+
}, async ({ pid, timeoutSeconds }) => {
|
|
303
|
+
const span = observability.toolSpan("wait_background", {
|
|
304
|
+
"mcp.command.pid": pid,
|
|
305
|
+
"mcp.command.timeout_seconds": timeoutSeconds,
|
|
306
|
+
});
|
|
307
|
+
const result = await backgroundManager.wait(pid, timeoutSeconds);
|
|
308
|
+
if (!result.view) {
|
|
309
|
+
span.end(false, { "mcp.command.status": "missing_process" });
|
|
310
|
+
return {
|
|
311
|
+
content: [
|
|
312
|
+
{
|
|
313
|
+
type: "text",
|
|
314
|
+
text: result.message,
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
isError: true,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (result.timedOut) {
|
|
321
|
+
observability.markTimeout("wait_background");
|
|
322
|
+
}
|
|
323
|
+
const logs = await backgroundManager.readLogs(pid);
|
|
324
|
+
const failed = !result.ok ||
|
|
325
|
+
result.timedOut ||
|
|
326
|
+
(typeof result.view.exitCode === "number" &&
|
|
327
|
+
result.view.exitCode !== 0);
|
|
328
|
+
span.end(!failed, {
|
|
329
|
+
"mcp.command.status": result.view.status,
|
|
330
|
+
"mcp.command.exit_code": result.view.exitCode ?? -1,
|
|
331
|
+
"mcp.command.timed_out": result.timedOut,
|
|
332
|
+
});
|
|
333
|
+
return {
|
|
334
|
+
content: [
|
|
335
|
+
{
|
|
336
|
+
type: "text",
|
|
337
|
+
text: [
|
|
338
|
+
result.message,
|
|
339
|
+
formatBackgroundView(result.view),
|
|
340
|
+
`STDOUT:\n${logs?.stdout?.trim() || "<empty>"}`,
|
|
341
|
+
`STDERR:\n${logs?.stderr?.trim() || "<empty>"}`,
|
|
342
|
+
].join("\n\n"),
|
|
343
|
+
},
|
|
344
|
+
],
|
|
345
|
+
isError: failed,
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bash-command-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sophisticated bash command MCP server that runs and manages shell execution.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/mrorigo/bash-command-mcp#readme",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/mrorigo/bash-command-mcp/issues"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/mrorigo/bash-command-mcp.git"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"bin": {
|
|
16
|
+
"bash-command-mcp": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md",
|
|
25
|
+
"Dockerfile",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"keywords": [
|
|
29
|
+
"mcp",
|
|
30
|
+
"model-context-protocol",
|
|
31
|
+
"bash",
|
|
32
|
+
"cli",
|
|
33
|
+
"stdio",
|
|
34
|
+
"server"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"clean": "rm -rf dist",
|
|
38
|
+
"build": "npm run clean && tsc -p tsconfig.build.json",
|
|
39
|
+
"start": "node dist/index.js",
|
|
40
|
+
"dev": "bun run --watch index.ts",
|
|
41
|
+
"lint": "oxlint -D typescript/no-explicit-any .",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"prepublishOnly": "npm run lint && npm run typecheck && npm run build"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=18"
|
|
47
|
+
},
|
|
48
|
+
"mcpName": "io.github.mrorigo/bash-command-mcp",
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/bun": "latest",
|
|
54
|
+
"@types/node": "^24.3.0",
|
|
55
|
+
"typescript": "^5.9.2"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
59
|
+
"oxlint": "^1.48.0",
|
|
60
|
+
"zod": "^4.3.6"
|
|
61
|
+
},
|
|
62
|
+
"optionalDependencies": {
|
|
63
|
+
"@opentelemetry/api": "^1.9.0",
|
|
64
|
+
"@opentelemetry/exporter-metrics-otlp-http": "^0.57.2",
|
|
65
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.57.2",
|
|
66
|
+
"@opentelemetry/resources": "^1.30.1",
|
|
67
|
+
"@opentelemetry/sdk-metrics": "^1.30.1",
|
|
68
|
+
"@opentelemetry/sdk-node": "^0.57.2",
|
|
69
|
+
"@opentelemetry/sdk-trace-base": "^1.30.1"
|
|
70
|
+
}
|
|
71
|
+
}
|