cli-mcp-mapper 1.0.2 → 1.0.4
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/.github/workflows/ci.yml +37 -0
- package/.github/workflows/publish.yml +3 -1
- package/README.md +16 -7
- package/index.js +1 -77
- package/lib.js +102 -0
- package/lib.test.js +630 -0
- package/package.json +7 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ "main", "develop" ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ "main", "develop" ]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
build-and-test:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
|
|
16
|
+
strategy:
|
|
17
|
+
matrix:
|
|
18
|
+
node-version: [18.x, 20.x, 22.x]
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- name: Checkout code
|
|
22
|
+
uses: actions/checkout@v4
|
|
23
|
+
|
|
24
|
+
- name: Setup Node.js ${{ matrix.node-version }}
|
|
25
|
+
uses: actions/setup-node@v4
|
|
26
|
+
with:
|
|
27
|
+
node-version: ${{ matrix.node-version }}
|
|
28
|
+
cache: 'npm'
|
|
29
|
+
|
|
30
|
+
- name: Install dependencies
|
|
31
|
+
run: npm ci
|
|
32
|
+
|
|
33
|
+
- name: Run tests
|
|
34
|
+
run: npm test
|
|
35
|
+
|
|
36
|
+
- name: Build (if build script exists)
|
|
37
|
+
run: npm run build --if-present
|
|
@@ -41,6 +41,9 @@ jobs:
|
|
|
41
41
|
echo "✅ Tag v$VERSION does not exist, proceeding..."
|
|
42
42
|
fi
|
|
43
43
|
|
|
44
|
+
- run: npm ci
|
|
45
|
+
- run: npm test
|
|
46
|
+
|
|
44
47
|
- name: Create and push tag
|
|
45
48
|
run: |
|
|
46
49
|
VERSION=${{ steps.package-version.outputs.version }}
|
|
@@ -50,6 +53,5 @@ jobs:
|
|
|
50
53
|
git push origin "v$VERSION"
|
|
51
54
|
echo "✅ Created and pushed tag v$VERSION"
|
|
52
55
|
|
|
53
|
-
- run: npm ci
|
|
54
56
|
- run: npm run build --if-present
|
|
55
57
|
- run: npm publish
|
package/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://raw.githubusercontent.com/SteffenBlake/cli-mcp-mapper/refs/heads/main/LICENSE)
|
|
4
4
|
[](https://www.npmjs.com/package/cli-mcp-mapper)
|
|
5
|
+
[](https://github.com/SteffenBlake/cli-mcp-mapper/actions/workflows/ci.yml)
|
|
5
6
|
|
|
6
7
|
**Transform any CLI command into a Model Context Protocol (MCP) tool with simple JSON configuration.**
|
|
7
8
|
|
|
@@ -622,26 +623,34 @@ jq -s '{"commands": (map(.commands) | add)}' \
|
|
|
622
623
|
|
|
623
624
|
⚠️ **Important Security Notes:**
|
|
624
625
|
|
|
625
|
-
1. **Command Injection
|
|
626
|
+
1. **Command Injection Protection**: CLI MCP Mapper uses Node.js `spawn` without shell interpretation to prevent command injection attacks. While we have comprehensive tests to validate this protection, **you should always run agents with shell access in containerized or sandboxed environments** (e.g., Docker, VM, or other isolation mechanisms) to minimize potential security risks. This provides an additional layer of defense in case of unforeseen vulnerabilities.
|
|
626
627
|
|
|
627
|
-
2. **
|
|
628
|
+
2. **Containerization Best Practice**: When deploying CLI MCP Mapper in production or exposing it to AI agents, strongly consider running it within:
|
|
629
|
+
- Docker containers with limited privileges
|
|
630
|
+
- Virtual machines with restricted network access
|
|
631
|
+
- Sandboxed environments with filesystem restrictions
|
|
632
|
+
- Isolated user accounts with minimal permissions
|
|
628
633
|
|
|
629
|
-
3. **
|
|
634
|
+
3. **Access Control**: MCP clients that connect to CLI MCP Mapper will have the same permissions as the user running it. Run the server with the least privileged user account necessary.
|
|
630
635
|
|
|
631
|
-
4. **
|
|
636
|
+
4. **Validate Parameters**: Use `enum` to restrict parameter values when possible to prevent unexpected inputs.
|
|
637
|
+
|
|
638
|
+
5. **Avoid Dangerous Commands**: Be cautious about exposing commands like:
|
|
632
639
|
- `rm -rf` without proper constraints
|
|
633
640
|
- `chmod` with unrestricted modes
|
|
634
641
|
- Commands that can execute arbitrary code
|
|
635
642
|
- Network commands that could expose sensitive data
|
|
643
|
+
- System administration commands
|
|
636
644
|
|
|
637
|
-
|
|
645
|
+
6. **Read-Only Operations**: Consider starting with read-only commands (ls, cat, git status) before exposing write operations.
|
|
638
646
|
|
|
639
|
-
|
|
647
|
+
7. **Configuration Security**:
|
|
640
648
|
- Don't commit sensitive configurations to version control
|
|
641
649
|
- Use environment-specific configuration files
|
|
642
650
|
- Review configurations before deploying
|
|
651
|
+
- Regularly audit which commands are exposed
|
|
643
652
|
|
|
644
|
-
|
|
653
|
+
8. **Least Privilege**: Only grant the minimum necessary commands for your use case.
|
|
645
654
|
|
|
646
655
|
**Best Practices:**
|
|
647
656
|
|
package/index.js
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
-
import { spawn } from "child_process";
|
|
7
6
|
import { readFile } from "fs/promises";
|
|
8
7
|
import { homedir } from "os";
|
|
9
8
|
import { join } from "path";
|
|
9
|
+
import { buildInputSchema, buildCommand, executeCommand } from "./lib.js";
|
|
10
10
|
|
|
11
11
|
// Load config from env variable or default path
|
|
12
12
|
const defaultConfig = join(homedir(), ".config", "cli-mcp-mapper", "commands.json");
|
|
@@ -61,82 +61,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
61
61
|
};
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
function buildInputSchema(parameters) {
|
|
65
|
-
const properties = {};
|
|
66
|
-
const required = [];
|
|
67
|
-
|
|
68
|
-
for (const [name, param] of Object.entries(parameters || {})) {
|
|
69
|
-
properties[name] = {
|
|
70
|
-
type: param.type,
|
|
71
|
-
description: param.description,
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
if (param.enum) properties[name].enum = param.enum;
|
|
75
|
-
if (param.default) properties[name].default = param.default;
|
|
76
|
-
if (param.required) required.push(name);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return {
|
|
80
|
-
type: "object",
|
|
81
|
-
properties,
|
|
82
|
-
required,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function buildCommand(commandConfig, args) {
|
|
87
|
-
const cmd = [commandConfig.command, ...(commandConfig.baseArgs || [])];
|
|
88
|
-
|
|
89
|
-
// Add positional args first
|
|
90
|
-
const positional = Object.entries(commandConfig.parameters || {})
|
|
91
|
-
.filter(([_, param]) => param.position !== undefined)
|
|
92
|
-
.sort(([_, a], [__, b]) => a.position - b.position);
|
|
93
|
-
|
|
94
|
-
for (const [name, param] of positional) {
|
|
95
|
-
if (args[name] !== undefined) {
|
|
96
|
-
cmd.push(String(args[name]));
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Add named args
|
|
101
|
-
for (const [name, param] of Object.entries(commandConfig.parameters || {})) {
|
|
102
|
-
if (param.position !== undefined) continue; // Skip positional
|
|
103
|
-
if (args[name] === undefined) continue; // Skip if not provided
|
|
104
|
-
|
|
105
|
-
if (param.type === "boolean") {
|
|
106
|
-
if (args[name] === true) {
|
|
107
|
-
cmd.push(param.argName);
|
|
108
|
-
if (param.argValue) cmd.push(param.argValue);
|
|
109
|
-
}
|
|
110
|
-
} else {
|
|
111
|
-
cmd.push(param.argName);
|
|
112
|
-
cmd.push(String(args[name]));
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return cmd;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function executeCommand(cmdArray) {
|
|
120
|
-
return new Promise((resolve, reject) => {
|
|
121
|
-
const [command, ...args] = cmdArray;
|
|
122
|
-
const proc = spawn(command, args, { shell: true });
|
|
123
|
-
|
|
124
|
-
let stdout = "";
|
|
125
|
-
let stderr = "";
|
|
126
|
-
|
|
127
|
-
proc.stdout.on("data", (data) => (stdout += data));
|
|
128
|
-
proc.stderr.on("data", (data) => (stderr += data));
|
|
129
|
-
|
|
130
|
-
proc.on("close", (code) => {
|
|
131
|
-
if (code !== 0) {
|
|
132
|
-
reject(new Error(`Command failed with code ${code}\n${stderr}`));
|
|
133
|
-
} else {
|
|
134
|
-
resolve(stdout || stderr);
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
|
|
140
64
|
// Start server
|
|
141
65
|
const transport = new StdioServerTransport();
|
|
142
66
|
await server.connect(transport);
|
package/lib.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build JSON schema for MCP tool input parameters
|
|
5
|
+
* @param {Object} parameters - Parameter definitions from config
|
|
6
|
+
* @returns {Object} JSON schema object
|
|
7
|
+
*/
|
|
8
|
+
export function buildInputSchema(parameters) {
|
|
9
|
+
const properties = {};
|
|
10
|
+
const required = [];
|
|
11
|
+
|
|
12
|
+
for (const [name, param] of Object.entries(parameters || {})) {
|
|
13
|
+
properties[name] = {
|
|
14
|
+
type: param.type,
|
|
15
|
+
description: param.description,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
if (param.enum) properties[name].enum = param.enum;
|
|
19
|
+
if (param.default) properties[name].default = param.default;
|
|
20
|
+
if (param.required) required.push(name);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties,
|
|
26
|
+
required,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build command array from config and arguments
|
|
32
|
+
* @param {Object} commandConfig - Command configuration
|
|
33
|
+
* @param {Object} args - Arguments passed to the tool
|
|
34
|
+
* @returns {Array<string>} Command array [command, arg1, arg2, ...]
|
|
35
|
+
*/
|
|
36
|
+
export function buildCommand(commandConfig, args) {
|
|
37
|
+
const cmd = [commandConfig.command, ...(commandConfig.baseArgs || [])];
|
|
38
|
+
|
|
39
|
+
// Add positional args first
|
|
40
|
+
const positional = Object.entries(commandConfig.parameters || {})
|
|
41
|
+
.filter(([_, param]) => param.position !== undefined)
|
|
42
|
+
.sort(([_, a], [__, b]) => a.position - b.position);
|
|
43
|
+
|
|
44
|
+
for (const [name, param] of positional) {
|
|
45
|
+
if (args[name] !== undefined) {
|
|
46
|
+
cmd.push(String(args[name]));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Add named args
|
|
51
|
+
for (const [name, param] of Object.entries(commandConfig.parameters || {})) {
|
|
52
|
+
if (param.position !== undefined) continue; // Skip positional
|
|
53
|
+
if (args[name] === undefined) continue; // Skip if not provided
|
|
54
|
+
|
|
55
|
+
if (param.type === "boolean") {
|
|
56
|
+
if (args[name] === true) {
|
|
57
|
+
cmd.push(param.argName);
|
|
58
|
+
if (param.argValue) cmd.push(param.argValue);
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
cmd.push(param.argName);
|
|
62
|
+
cmd.push(String(args[name]));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return cmd;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Execute command safely without shell interpretation
|
|
71
|
+
* @param {Array<string>} cmdArray - Command array [command, arg1, arg2, ...]
|
|
72
|
+
* @returns {Promise<string>} Command output
|
|
73
|
+
*/
|
|
74
|
+
export function executeCommand(cmdArray) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const [command, ...args] = cmdArray;
|
|
77
|
+
// SECURITY FIX: Remove shell: true to prevent command injection
|
|
78
|
+
// spawn will now execute the command directly without shell interpretation
|
|
79
|
+
const proc = spawn(command, args);
|
|
80
|
+
|
|
81
|
+
let stdout = "";
|
|
82
|
+
let stderr = "";
|
|
83
|
+
|
|
84
|
+
proc.stdout.on("data", (data) => (stdout += data));
|
|
85
|
+
proc.stderr.on("data", (data) => (stderr += data));
|
|
86
|
+
|
|
87
|
+
proc.on("close", (code) => {
|
|
88
|
+
if (code !== 0) {
|
|
89
|
+
// Resolve with full output instead of rejecting
|
|
90
|
+
// This allows MCP SDK clients to see the actual error details
|
|
91
|
+
const output = `Command exited with code ${code}\n${stdout}${stderr}`;
|
|
92
|
+
resolve(output);
|
|
93
|
+
} else {
|
|
94
|
+
resolve(stdout || stderr);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
proc.on("error", (err) => {
|
|
99
|
+
reject(new Error(`Failed to execute command: ${err.message}`));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
package/lib.test.js
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import { buildInputSchema, buildCommand, executeCommand } from './lib.js';
|
|
3
|
+
|
|
4
|
+
describe('buildInputSchema', () => {
|
|
5
|
+
it('should build schema with string parameter', () => {
|
|
6
|
+
const params = {
|
|
7
|
+
name: {
|
|
8
|
+
type: 'string',
|
|
9
|
+
description: 'User name',
|
|
10
|
+
required: true
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const schema = buildInputSchema(params);
|
|
15
|
+
|
|
16
|
+
expect(schema).toEqual({
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
name: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description: 'User name'
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
required: ['name']
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should build schema with boolean parameter', () => {
|
|
29
|
+
const params = {
|
|
30
|
+
verbose: {
|
|
31
|
+
type: 'boolean',
|
|
32
|
+
description: 'Enable verbose output'
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const schema = buildInputSchema(params);
|
|
37
|
+
|
|
38
|
+
expect(schema).toEqual({
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
verbose: {
|
|
42
|
+
type: 'boolean',
|
|
43
|
+
description: 'Enable verbose output'
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
required: []
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should build schema with number parameter', () => {
|
|
51
|
+
const params = {
|
|
52
|
+
count: {
|
|
53
|
+
type: 'number',
|
|
54
|
+
description: 'Number of items',
|
|
55
|
+
default: 10
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const schema = buildInputSchema(params);
|
|
60
|
+
|
|
61
|
+
expect(schema).toEqual({
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
count: {
|
|
65
|
+
type: 'number',
|
|
66
|
+
description: 'Number of items',
|
|
67
|
+
default: 10
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
required: []
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should build schema with enum parameter', () => {
|
|
75
|
+
const params = {
|
|
76
|
+
level: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
description: 'Log level',
|
|
79
|
+
enum: ['debug', 'info', 'warn', 'error']
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const schema = buildInputSchema(params);
|
|
84
|
+
|
|
85
|
+
expect(schema).toEqual({
|
|
86
|
+
type: 'object',
|
|
87
|
+
properties: {
|
|
88
|
+
level: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
description: 'Log level',
|
|
91
|
+
enum: ['debug', 'info', 'warn', 'error']
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
required: []
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should handle empty parameters', () => {
|
|
99
|
+
const schema = buildInputSchema({});
|
|
100
|
+
|
|
101
|
+
expect(schema).toEqual({
|
|
102
|
+
type: 'object',
|
|
103
|
+
properties: {},
|
|
104
|
+
required: []
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should handle undefined parameters', () => {
|
|
109
|
+
const schema = buildInputSchema(undefined);
|
|
110
|
+
|
|
111
|
+
expect(schema).toEqual({
|
|
112
|
+
type: 'object',
|
|
113
|
+
properties: {},
|
|
114
|
+
required: []
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('buildCommand', () => {
|
|
120
|
+
it('should build basic command with no args', () => {
|
|
121
|
+
const config = {
|
|
122
|
+
command: 'echo',
|
|
123
|
+
baseArgs: ['hello']
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const cmd = buildCommand(config, {});
|
|
127
|
+
|
|
128
|
+
expect(cmd).toEqual(['echo', 'hello']);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should build command with positional arguments', () => {
|
|
132
|
+
const config = {
|
|
133
|
+
command: 'echo',
|
|
134
|
+
baseArgs: [],
|
|
135
|
+
parameters: {
|
|
136
|
+
message: {
|
|
137
|
+
type: 'string',
|
|
138
|
+
description: 'Message to print',
|
|
139
|
+
position: 0
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const cmd = buildCommand(config, { message: 'test message' });
|
|
145
|
+
|
|
146
|
+
expect(cmd).toEqual(['echo', 'test message']);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should build command with multiple positional arguments in order', () => {
|
|
150
|
+
const config = {
|
|
151
|
+
command: 'cp',
|
|
152
|
+
baseArgs: [],
|
|
153
|
+
parameters: {
|
|
154
|
+
source: {
|
|
155
|
+
type: 'string',
|
|
156
|
+
description: 'Source file',
|
|
157
|
+
position: 0
|
|
158
|
+
},
|
|
159
|
+
dest: {
|
|
160
|
+
type: 'string',
|
|
161
|
+
description: 'Destination file',
|
|
162
|
+
position: 1
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const cmd = buildCommand(config, { source: 'file1.txt', dest: 'file2.txt' });
|
|
168
|
+
|
|
169
|
+
expect(cmd).toEqual(['cp', 'file1.txt', 'file2.txt']);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should build command with named string argument', () => {
|
|
173
|
+
const config = {
|
|
174
|
+
command: 'git',
|
|
175
|
+
baseArgs: ['commit'],
|
|
176
|
+
parameters: {
|
|
177
|
+
message: {
|
|
178
|
+
type: 'string',
|
|
179
|
+
description: 'Commit message',
|
|
180
|
+
argName: '-m'
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const cmd = buildCommand(config, { message: 'Initial commit' });
|
|
186
|
+
|
|
187
|
+
expect(cmd).toEqual(['git', 'commit', '-m', 'Initial commit']);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should build command with boolean flag when true', () => {
|
|
191
|
+
const config = {
|
|
192
|
+
command: 'ls',
|
|
193
|
+
baseArgs: [],
|
|
194
|
+
parameters: {
|
|
195
|
+
all: {
|
|
196
|
+
type: 'boolean',
|
|
197
|
+
description: 'Show all files',
|
|
198
|
+
argName: '-a'
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const cmd = buildCommand(config, { all: true });
|
|
204
|
+
|
|
205
|
+
expect(cmd).toEqual(['ls', '-a']);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should omit boolean flag when false', () => {
|
|
209
|
+
const config = {
|
|
210
|
+
command: 'ls',
|
|
211
|
+
baseArgs: [],
|
|
212
|
+
parameters: {
|
|
213
|
+
all: {
|
|
214
|
+
type: 'boolean',
|
|
215
|
+
description: 'Show all files',
|
|
216
|
+
argName: '-a'
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const cmd = buildCommand(config, { all: false });
|
|
222
|
+
|
|
223
|
+
expect(cmd).toEqual(['ls']);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should build command with mixed positional and named args', () => {
|
|
227
|
+
const config = {
|
|
228
|
+
command: 'find',
|
|
229
|
+
baseArgs: [],
|
|
230
|
+
parameters: {
|
|
231
|
+
path: {
|
|
232
|
+
type: 'string',
|
|
233
|
+
description: 'Search path',
|
|
234
|
+
position: 0
|
|
235
|
+
},
|
|
236
|
+
name: {
|
|
237
|
+
type: 'string',
|
|
238
|
+
description: 'File name pattern',
|
|
239
|
+
argName: '-name'
|
|
240
|
+
},
|
|
241
|
+
type: {
|
|
242
|
+
type: 'string',
|
|
243
|
+
description: 'File type',
|
|
244
|
+
argName: '-type'
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const cmd = buildCommand(config, {
|
|
250
|
+
path: '/tmp',
|
|
251
|
+
name: '*.txt',
|
|
252
|
+
type: 'f'
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(cmd).toEqual(['find', '/tmp', '-name', '*.txt', '-type', 'f']);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should convert number arguments to strings', () => {
|
|
259
|
+
const config = {
|
|
260
|
+
command: 'head',
|
|
261
|
+
baseArgs: [],
|
|
262
|
+
parameters: {
|
|
263
|
+
lines: {
|
|
264
|
+
type: 'number',
|
|
265
|
+
description: 'Number of lines',
|
|
266
|
+
argName: '-n'
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const cmd = buildCommand(config, { lines: 10 });
|
|
272
|
+
|
|
273
|
+
expect(cmd).toEqual(['head', '-n', '10']);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should handle boolean with argValue', () => {
|
|
277
|
+
const config = {
|
|
278
|
+
command: 'test-cmd',
|
|
279
|
+
baseArgs: [],
|
|
280
|
+
parameters: {
|
|
281
|
+
flag: {
|
|
282
|
+
type: 'boolean',
|
|
283
|
+
description: 'Test flag',
|
|
284
|
+
argName: '--flag',
|
|
285
|
+
argValue: 'value'
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const cmd = buildCommand(config, { flag: true });
|
|
291
|
+
|
|
292
|
+
expect(cmd).toEqual(['test-cmd', '--flag', 'value']);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('Command Injection Security Tests', () => {
|
|
297
|
+
describe('String parameter injection attempts', () => {
|
|
298
|
+
it('should safely handle command substitution attempt with $(...)', async () => {
|
|
299
|
+
const config = {
|
|
300
|
+
command: 'echo',
|
|
301
|
+
baseArgs: [],
|
|
302
|
+
parameters: {
|
|
303
|
+
message: {
|
|
304
|
+
type: 'string',
|
|
305
|
+
description: 'Message',
|
|
306
|
+
position: 0
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const cmd = buildCommand(config, { message: '$(whoami)' });
|
|
312
|
+
|
|
313
|
+
// Command should be built as array, not interpreted by shell
|
|
314
|
+
expect(cmd).toEqual(['echo', '$(whoami)']);
|
|
315
|
+
|
|
316
|
+
// Execute and verify the literal string is echoed, not evaluated
|
|
317
|
+
const result = await executeCommand(cmd);
|
|
318
|
+
expect(result.trim()).toBe('$(whoami)');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should safely handle command substitution with backticks', async () => {
|
|
322
|
+
const config = {
|
|
323
|
+
command: 'echo',
|
|
324
|
+
baseArgs: [],
|
|
325
|
+
parameters: {
|
|
326
|
+
message: {
|
|
327
|
+
type: 'string',
|
|
328
|
+
description: 'Message',
|
|
329
|
+
position: 0
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const cmd = buildCommand(config, { message: '`whoami`' });
|
|
335
|
+
|
|
336
|
+
expect(cmd).toEqual(['echo', '`whoami`']);
|
|
337
|
+
|
|
338
|
+
const result = await executeCommand(cmd);
|
|
339
|
+
expect(result.trim()).toBe('`whoami`');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should safely handle pipe attempts', async () => {
|
|
343
|
+
const config = {
|
|
344
|
+
command: 'echo',
|
|
345
|
+
baseArgs: [],
|
|
346
|
+
parameters: {
|
|
347
|
+
message: {
|
|
348
|
+
type: 'string',
|
|
349
|
+
description: 'Message',
|
|
350
|
+
position: 0
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const cmd = buildCommand(config, { message: 'test | cat /etc/passwd' });
|
|
356
|
+
|
|
357
|
+
expect(cmd).toEqual(['echo', 'test | cat /etc/passwd']);
|
|
358
|
+
|
|
359
|
+
const result = await executeCommand(cmd);
|
|
360
|
+
expect(result.trim()).toBe('test | cat /etc/passwd');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('should safely handle semicolon command chaining', async () => {
|
|
364
|
+
const config = {
|
|
365
|
+
command: 'echo',
|
|
366
|
+
baseArgs: [],
|
|
367
|
+
parameters: {
|
|
368
|
+
message: {
|
|
369
|
+
type: 'string',
|
|
370
|
+
description: 'Message',
|
|
371
|
+
position: 0
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const cmd = buildCommand(config, { message: 'hello; rm -rf /' });
|
|
377
|
+
|
|
378
|
+
expect(cmd).toEqual(['echo', 'hello; rm -rf /']);
|
|
379
|
+
|
|
380
|
+
const result = await executeCommand(cmd);
|
|
381
|
+
expect(result.trim()).toBe('hello; rm -rf /');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('should safely handle && command chaining', async () => {
|
|
385
|
+
const config = {
|
|
386
|
+
command: 'echo',
|
|
387
|
+
baseArgs: [],
|
|
388
|
+
parameters: {
|
|
389
|
+
message: {
|
|
390
|
+
type: 'string',
|
|
391
|
+
description: 'Message',
|
|
392
|
+
position: 0
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const cmd = buildCommand(config, { message: 'test && cat /etc/passwd' });
|
|
398
|
+
|
|
399
|
+
expect(cmd).toEqual(['echo', 'test && cat /etc/passwd']);
|
|
400
|
+
|
|
401
|
+
const result = await executeCommand(cmd);
|
|
402
|
+
expect(result.trim()).toBe('test && cat /etc/passwd');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('should safely handle || command chaining', async () => {
|
|
406
|
+
const config = {
|
|
407
|
+
command: 'echo',
|
|
408
|
+
baseArgs: [],
|
|
409
|
+
parameters: {
|
|
410
|
+
message: {
|
|
411
|
+
type: 'string',
|
|
412
|
+
description: 'Message',
|
|
413
|
+
position: 0
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const cmd = buildCommand(config, { message: 'test || whoami' });
|
|
419
|
+
|
|
420
|
+
expect(cmd).toEqual(['echo', 'test || whoami']);
|
|
421
|
+
|
|
422
|
+
const result = await executeCommand(cmd);
|
|
423
|
+
expect(result.trim()).toBe('test || whoami');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('should safely handle redirection attempts', async () => {
|
|
427
|
+
const config = {
|
|
428
|
+
command: 'echo',
|
|
429
|
+
baseArgs: [],
|
|
430
|
+
parameters: {
|
|
431
|
+
message: {
|
|
432
|
+
type: 'string',
|
|
433
|
+
description: 'Message',
|
|
434
|
+
position: 0
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const cmd = buildCommand(config, { message: 'test > /tmp/hacked' });
|
|
440
|
+
|
|
441
|
+
expect(cmd).toEqual(['echo', 'test > /tmp/hacked']);
|
|
442
|
+
|
|
443
|
+
const result = await executeCommand(cmd);
|
|
444
|
+
expect(result.trim()).toBe('test > /tmp/hacked');
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('should safely handle newline injection', async () => {
|
|
448
|
+
const config = {
|
|
449
|
+
command: 'echo',
|
|
450
|
+
baseArgs: [],
|
|
451
|
+
parameters: {
|
|
452
|
+
message: {
|
|
453
|
+
type: 'string',
|
|
454
|
+
description: 'Message',
|
|
455
|
+
position: 0
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const cmd = buildCommand(config, { message: 'test\nwhoami' });
|
|
461
|
+
|
|
462
|
+
expect(cmd).toEqual(['echo', 'test\nwhoami']);
|
|
463
|
+
|
|
464
|
+
const result = await executeCommand(cmd);
|
|
465
|
+
expect(result).toContain('test\nwhoami');
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
describe('Named argument injection attempts', () => {
|
|
470
|
+
it('should safely handle injection in named string arguments', async () => {
|
|
471
|
+
const config = {
|
|
472
|
+
command: 'git',
|
|
473
|
+
baseArgs: ['commit'],
|
|
474
|
+
parameters: {
|
|
475
|
+
message: {
|
|
476
|
+
type: 'string',
|
|
477
|
+
description: 'Commit message',
|
|
478
|
+
argName: '-m'
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const cmd = buildCommand(config, { message: '$(whoami) hack' });
|
|
484
|
+
|
|
485
|
+
// Should be separate array elements, not shell-interpreted
|
|
486
|
+
expect(cmd).toEqual(['git', 'commit', '-m', '$(whoami) hack']);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('should safely handle injection attempts in number arguments', async () => {
|
|
490
|
+
const config = {
|
|
491
|
+
command: 'head',
|
|
492
|
+
baseArgs: [],
|
|
493
|
+
parameters: {
|
|
494
|
+
lines: {
|
|
495
|
+
type: 'number',
|
|
496
|
+
description: 'Number of lines',
|
|
497
|
+
argName: '-n'
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
// Try to inject through number parameter
|
|
503
|
+
const cmd = buildCommand(config, { lines: '10; whoami' });
|
|
504
|
+
|
|
505
|
+
expect(cmd).toEqual(['head', '-n', '10; whoami']);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
describe('Boolean parameter injection attempts', () => {
|
|
510
|
+
it('should not allow injection through boolean parameters', () => {
|
|
511
|
+
const config = {
|
|
512
|
+
command: 'ls',
|
|
513
|
+
baseArgs: [],
|
|
514
|
+
parameters: {
|
|
515
|
+
all: {
|
|
516
|
+
type: 'boolean',
|
|
517
|
+
description: 'Show all',
|
|
518
|
+
argName: '-a'
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
// Boolean parameters only add the flag when value is exactly true
|
|
524
|
+
// String values won't trigger the flag even if truthy in JavaScript
|
|
525
|
+
const cmd = buildCommand(config, { all: '$(whoami)' });
|
|
526
|
+
|
|
527
|
+
// The string '$(whoami)' is truthy but not === true, so flag should be omitted
|
|
528
|
+
expect(cmd).toEqual(['ls']);
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe('Multiple parameter injection attempts', () => {
|
|
533
|
+
it('should safely handle injection across multiple parameters', async () => {
|
|
534
|
+
const config = {
|
|
535
|
+
command: 'find',
|
|
536
|
+
baseArgs: [],
|
|
537
|
+
parameters: {
|
|
538
|
+
path: {
|
|
539
|
+
type: 'string',
|
|
540
|
+
description: 'Search path',
|
|
541
|
+
position: 0
|
|
542
|
+
},
|
|
543
|
+
name: {
|
|
544
|
+
type: 'string',
|
|
545
|
+
description: 'File name',
|
|
546
|
+
argName: '-name'
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const cmd = buildCommand(config, {
|
|
552
|
+
path: '.; whoami; echo',
|
|
553
|
+
name: '*.txt && cat /etc/passwd'
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// Both should remain as literal strings
|
|
557
|
+
expect(cmd).toEqual(['find', '.; whoami; echo', '-name', '*.txt && cat /etc/passwd']);
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
describe('Error handling', () => {
|
|
563
|
+
it('should resolve with exit code info when command fails', async () => {
|
|
564
|
+
const cmd = ['false']; // 'false' command always returns exit code 1
|
|
565
|
+
|
|
566
|
+
const result = await executeCommand(cmd);
|
|
567
|
+
expect(result).toContain('Command exited with code');
|
|
568
|
+
expect(result).toContain('1');
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('should reject when command does not exist', async () => {
|
|
572
|
+
const cmd = ['this-command-definitely-does-not-exist-12345'];
|
|
573
|
+
|
|
574
|
+
await expect(executeCommand(cmd)).rejects.toThrow('Failed to execute command');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('should resolve with stderr content on failure', async () => {
|
|
578
|
+
// Use a command that writes to stderr
|
|
579
|
+
const cmd = ['ls', '/this/path/does/not/exist/12345'];
|
|
580
|
+
|
|
581
|
+
const result = await executeCommand(cmd);
|
|
582
|
+
expect(result).toContain('Command exited with code');
|
|
583
|
+
expect(result.toLowerCase()).toMatch(/no such file or directory|cannot access/);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('should resolve with full output including stderr when command fails with non-zero exit code', async () => {
|
|
587
|
+
// Use a command that will fail and output to stderr
|
|
588
|
+
// This command tries to list a non-existent directory
|
|
589
|
+
const cmd = ['ls', '/this/path/does/not/exist/for/testing/12345'];
|
|
590
|
+
|
|
591
|
+
// The promise should resolve (not reject) with the full output
|
|
592
|
+
const result = await executeCommand(cmd);
|
|
593
|
+
|
|
594
|
+
// Result should contain exit code information
|
|
595
|
+
expect(result).toContain('Command exited with code');
|
|
596
|
+
|
|
597
|
+
// Result should contain stderr output with error message
|
|
598
|
+
expect(result.toLowerCase()).toMatch(/no such file or directory|cannot access/);
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
describe('Normal operation tests', () => {
|
|
603
|
+
it('should execute simple echo command', async () => {
|
|
604
|
+
const cmd = ['echo', 'hello world'];
|
|
605
|
+
const result = await executeCommand(cmd);
|
|
606
|
+
|
|
607
|
+
expect(result.trim()).toBe('hello world');
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('should execute command with multiple arguments', async () => {
|
|
611
|
+
const cmd = ['echo', 'arg1', 'arg2', 'arg3'];
|
|
612
|
+
const result = await executeCommand(cmd);
|
|
613
|
+
|
|
614
|
+
expect(result.trim()).toBe('arg1 arg2 arg3');
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('should handle arguments with spaces', async () => {
|
|
618
|
+
const cmd = ['echo', 'hello world', 'foo bar'];
|
|
619
|
+
const result = await executeCommand(cmd);
|
|
620
|
+
|
|
621
|
+
expect(result.trim()).toBe('hello world foo bar');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('should handle arguments with special characters (non-shell)', async () => {
|
|
625
|
+
const cmd = ['echo', '@#$%^&*()'];
|
|
626
|
+
const result = await executeCommand(cmd);
|
|
627
|
+
|
|
628
|
+
expect(result.trim()).toBe('@#$%^&*()');
|
|
629
|
+
});
|
|
630
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cli-mcp-mapper",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "mcp server for mapping cli commands to mcp tools",
|
|
5
5
|
"homepage": "https://github.com/SteffenBlake/cli-mcp-mapper#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -16,7 +16,13 @@
|
|
|
16
16
|
"cli-mcp-mapper": "index.js"
|
|
17
17
|
},
|
|
18
18
|
"type": "module",
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
|
|
21
|
+
},
|
|
19
22
|
"dependencies": {
|
|
20
23
|
"@modelcontextprotocol/sdk": "^1.25.3"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"jest": "^30.2.0"
|
|
21
27
|
}
|
|
22
28
|
}
|