cli-mcp-mapper 1.0.1 → 1.0.3
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 +21 -18
- package/index.js +1 -77
- package/lib.js +99 -0
- package/lib.test.js +611 -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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# CLI MCP Mapper
|
|
2
2
|
|
|
3
|
-
[](https://
|
|
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
|
|
|
@@ -66,22 +67,16 @@ The Model Context Protocol (MCP) enables AI assistants to interact with external
|
|
|
66
67
|
|
|
67
68
|
## Installation
|
|
68
69
|
|
|
69
|
-
Install CLI MCP Mapper globally from
|
|
70
|
+
Install CLI MCP Mapper globally from NPM:
|
|
70
71
|
|
|
71
72
|
```bash
|
|
72
|
-
npm
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
Or install from npm (once published):
|
|
76
|
-
|
|
77
|
-
```bash
|
|
78
|
-
npm install -g cli-mcp-mapper
|
|
73
|
+
npm i -g cli-mcp-mapper
|
|
79
74
|
```
|
|
80
75
|
|
|
81
76
|
Verify the installation:
|
|
82
77
|
|
|
83
78
|
```bash
|
|
84
|
-
cli-mcp-mapper
|
|
79
|
+
which cli-mcp-mapper
|
|
85
80
|
```
|
|
86
81
|
|
|
87
82
|
## Configuration
|
|
@@ -628,26 +623,34 @@ jq -s '{"commands": (map(.commands) | add)}' \
|
|
|
628
623
|
|
|
629
624
|
⚠️ **Important Security Notes:**
|
|
630
625
|
|
|
631
|
-
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.
|
|
627
|
+
|
|
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
|
|
632
633
|
|
|
633
|
-
|
|
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.
|
|
634
635
|
|
|
635
|
-
|
|
636
|
+
4. **Validate Parameters**: Use `enum` to restrict parameter values when possible to prevent unexpected inputs.
|
|
636
637
|
|
|
637
|
-
|
|
638
|
+
5. **Avoid Dangerous Commands**: Be cautious about exposing commands like:
|
|
638
639
|
- `rm -rf` without proper constraints
|
|
639
640
|
- `chmod` with unrestricted modes
|
|
640
641
|
- Commands that can execute arbitrary code
|
|
641
642
|
- Network commands that could expose sensitive data
|
|
643
|
+
- System administration commands
|
|
642
644
|
|
|
643
|
-
|
|
645
|
+
6. **Read-Only Operations**: Consider starting with read-only commands (ls, cat, git status) before exposing write operations.
|
|
644
646
|
|
|
645
|
-
|
|
647
|
+
7. **Configuration Security**:
|
|
646
648
|
- Don't commit sensitive configurations to version control
|
|
647
649
|
- Use environment-specific configuration files
|
|
648
650
|
- Review configurations before deploying
|
|
651
|
+
- Regularly audit which commands are exposed
|
|
649
652
|
|
|
650
|
-
|
|
653
|
+
8. **Least Privilege**: Only grant the minimum necessary commands for your use case.
|
|
651
654
|
|
|
652
655
|
**Best Practices:**
|
|
653
656
|
|
|
@@ -702,4 +705,4 @@ MIT License - see [LICENSE](LICENSE) file for details.
|
|
|
702
705
|
|
|
703
706
|
**Made with ❤️ for the MCP community**
|
|
704
707
|
|
|
705
|
-
For issues, questions, or contributions, visit: https://github.com/SteffenBlake/cli-mcp-mapper
|
|
708
|
+
For issues, questions, or contributions, visit: https://github.com/SteffenBlake/cli-mcp-mapper
|
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,99 @@
|
|
|
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
|
+
reject(new Error(`Command failed with code ${code}\n${stderr}`));
|
|
90
|
+
} else {
|
|
91
|
+
resolve(stdout || stderr);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
proc.on("error", (err) => {
|
|
96
|
+
reject(new Error(`Failed to execute command: ${err.message}`));
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
package/lib.test.js
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
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 reject when command fails', async () => {
|
|
564
|
+
const cmd = ['false']; // 'false' command always returns exit code 1
|
|
565
|
+
|
|
566
|
+
await expect(executeCommand(cmd)).rejects.toThrow('Command failed with code 1');
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('should reject when command does not exist', async () => {
|
|
570
|
+
const cmd = ['this-command-definitely-does-not-exist-12345'];
|
|
571
|
+
|
|
572
|
+
await expect(executeCommand(cmd)).rejects.toThrow('Failed to execute command');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('should capture stderr on failure', async () => {
|
|
576
|
+
// Use a command that writes to stderr
|
|
577
|
+
const cmd = ['ls', '/this/path/does/not/exist/12345'];
|
|
578
|
+
|
|
579
|
+
await expect(executeCommand(cmd)).rejects.toThrow();
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
describe('Normal operation tests', () => {
|
|
584
|
+
it('should execute simple echo command', async () => {
|
|
585
|
+
const cmd = ['echo', 'hello world'];
|
|
586
|
+
const result = await executeCommand(cmd);
|
|
587
|
+
|
|
588
|
+
expect(result.trim()).toBe('hello world');
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('should execute command with multiple arguments', async () => {
|
|
592
|
+
const cmd = ['echo', 'arg1', 'arg2', 'arg3'];
|
|
593
|
+
const result = await executeCommand(cmd);
|
|
594
|
+
|
|
595
|
+
expect(result.trim()).toBe('arg1 arg2 arg3');
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('should handle arguments with spaces', async () => {
|
|
599
|
+
const cmd = ['echo', 'hello world', 'foo bar'];
|
|
600
|
+
const result = await executeCommand(cmd);
|
|
601
|
+
|
|
602
|
+
expect(result.trim()).toBe('hello world foo bar');
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('should handle arguments with special characters (non-shell)', async () => {
|
|
606
|
+
const cmd = ['echo', '@#$%^&*()'];
|
|
607
|
+
const result = await executeCommand(cmd);
|
|
608
|
+
|
|
609
|
+
expect(result.trim()).toBe('@#$%^&*()');
|
|
610
|
+
});
|
|
611
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cli-mcp-mapper",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
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
|
}
|