npm-run-mcp-server 0.2.9 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,19 +18,28 @@
18
18
 
19
19
  - [Install](#install)
20
20
  - [Usage](#usage)
21
+ - [As an MCP Server](#as-an-mcp-server)
22
+ - [As a CLI Tool](#as-a-cli-tool)
21
23
  - [Configuration](#configuration)
22
- - [GitHub Copilot Chat (VS Code)](#github-copilot-chat-vs-code)
23
- - [Claude Code (VS Code extension)](#claude-code-vs-code-extension)
24
- - [Claude Code (terminal / standalone)](#claude-code-terminal--standalone)
24
+ - [GitHub Copilot (VS Code)](#github-copilot-vs-code)
25
25
  - [Cursor](#cursor)
26
+ - [Claude Code](#claude-code)
27
+ - [Multi-Project Workflow](#multi-project-workflow)
28
+ - [Auto-Restart on Script Changes](#auto-restart-on-script-changes)
29
+ - [Script Exposure Config](#script-exposure-config)
26
30
  - [Install from source](#install-from-source)
27
31
  - [Testing with MCP Inspector](#testing-with-mcp-inspector)
28
32
  - [CLI Options](#cli-options)
29
33
  - [Contributing](#contributing)
34
+ - [Reporting Issues](#reporting-issues)
35
+ - [Submitting Changes](#submitting-changes)
36
+ - [Development Setup](#development-setup)
30
37
  - [License](#license)
31
38
 
32
39
  ## Install
33
40
 
41
+ Installation options.
42
+
34
43
  ```bash
35
44
  npm i -D npm-run-mcp-server
36
45
  # or globally
@@ -41,17 +50,21 @@ npx npm-run-mcp-server
41
50
 
42
51
  ## Usage
43
52
 
53
+ MCP server and CLI tool usage.
54
+
44
55
  ### As an MCP Server
45
56
 
46
57
  Add this server to your MCP host configuration. It uses stdio and automatically detects your project's `package.json` using workspace environment variables or by walking up from the current working directory.
47
58
 
48
59
  **Key Features:**
49
60
  - **Automatic Workspace Detection**: Works seamlessly across different projects without configuration changes
50
- - **Smart Tool Names**: Script names with colons (like `install:discord`) are automatically converted to valid tool names (`install_discord`)
61
+ - **Smart Tool Names**: Script names with colons (like `test:unit`) are automatically converted to valid tool names (`test_unit`)
51
62
  - **Rich Descriptions**: Each tool includes the actual script command in its description
52
63
  - **Package Manager Detection**: Automatically detects npm, pnpm, yarn, or bun
53
- - **Optional Arguments**: Each tool accepts an optional `args` string that is appended after `--` when running the script
54
- - **Auto-Restart on Changes**: Automatically restarts when `package.json` scripts are modified, ensuring tools are always up-to-date
64
+ - **Optional Arguments**: Each tool accepts optional `args` (`string` or `string[]`) appended after `--` when running the script
65
+ - **Auto-Restart on Changes**: Automatically restarts when `package.json` or config changes, ensuring tools are always up-to-date
66
+
67
+ Note: scripts run inside the target project. If they rely on local dependencies (eslint, vitest, tsc), install them first (for example, `npm install`).
55
68
 
56
69
  ### As a CLI Tool
57
70
 
@@ -69,12 +82,23 @@ npx npm-run-mcp-server --cwd /path/to/project --list-scripts
69
82
 
70
83
  # Override package manager detection
71
84
  npx npm-run-mcp-server --pm yarn --list-scripts
85
+
86
+ # Use an explicit config file (relative to the project directory, or absolute)
87
+ npx npm-run-mcp-server --cwd /path/to/project --config npm-run-mcp.config.json --verbose
72
88
  ```
73
89
 
74
90
  ## Configuration
75
91
 
76
- ### Install in GitHub Copilot Chat (VS Code)
92
+ Setup instructions for AI agents.
93
+
94
+ ### GitHub Copilot (VS Code)
95
+
96
+ #### Via UI
97
+ 1. Open VS Code settings
98
+ 2. Search for "MCP"
99
+ 3. Add server configuration in settings.json
77
100
 
101
+ #### Via Config File
78
102
  Option A — per-workspace via `.vscode/mcp.json` (recommended for multi-project use):
79
103
 
80
104
  ```json
@@ -101,39 +125,23 @@ Option B — user settings (`settings.json`):
101
125
  }
102
126
  ```
103
127
 
104
- **Note**: The server automatically detects the current project's `package.json` using workspace environment variables (like `WORKSPACE_FOLDER_PATHS`) or by walking up from the current working directory. No hardcoded paths are needed - it works seamlessly across all your projects.
105
-
106
128
  Then open Copilot Chat, switch to Agent mode, and start the `npm-scripts` server from the tools panel.
107
129
 
108
- ### Multi-Project Workflow
109
-
110
- The MCP server is designed to work seamlessly across multiple projects without configuration changes:
111
-
112
- - **VS Code/Cursor**: The server automatically detects the current workspace using environment variables like `WORKSPACE_FOLDER_PATHS`
113
- - **Claude Desktop**: The server uses the working directory where Claude is launched
114
- - **No Hardcoded Paths**: All examples use `npx npm-run-mcp-server` without `--cwd` flags
115
- - **Smart Detection**: The server first tries workspace environment variables, then falls back to walking up the directory tree to find the nearest `package.json`
116
- - **Cross-Platform**: Handles Windows/WSL path conversions automatically
117
-
118
- This means you can use the same MCP configuration across all your projects, and the server will automatically target the correct project based on your current workspace.
119
-
120
- ### Auto-Restart on Script Changes
121
-
122
- The MCP server automatically monitors your `package.json` file for changes. When you add, remove, or modify scripts, the server will:
123
-
124
- 1. **Detect the change** and log it (with `--verbose` flag)
125
- 2. **Gracefully exit** to allow the MCP client to restart the server
126
- 3. **Reload with new tools** based on the updated scripts
130
+ ### Cursor
127
131
 
128
- This ensures your MCP tools are always synchronized with your current `package.json` scripts without manual intervention.
132
+ #### Via UI
133
+ 1. Open Settings -> MCP Servers -> Add MCP Server
134
+ 2. Type: NPX Package
135
+ 3. Command: `npx`
136
+ 4. Arguments: `-y npm-run-mcp-server`
137
+ 5. Save and start the server from the tools list
129
138
 
130
- ### Install in Claude Code (VS Code extension)
131
-
132
- Add to VS Code user/workspace settings (`settings.json`):
139
+ #### Via Config File
140
+ Add to Cursor's MCP configuration:
133
141
 
134
142
  ```json
135
143
  {
136
- "claude.mcpServers": {
144
+ "servers": {
137
145
  "npm-scripts": {
138
146
  "command": "npx",
139
147
  "args": ["-y", "npm-run-mcp-server"]
@@ -142,20 +150,19 @@ Add to VS Code user/workspace settings (`settings.json`):
142
150
  }
143
151
  ```
144
152
 
145
- **Note**: Workspace settings (`.vscode/settings.json`) are recommended for multi-project use, as they automatically target the current project.
146
-
147
- Restart the extension and confirm the server/tools appear.
153
+ ### Claude Code
148
154
 
149
- ### Install in Claude Code (terminal / standalone)
150
-
151
- Add this server to Claude's global config file (paths vary by OS). Create the file if it doesn't exist.
155
+ #### Via Terminal
156
+ ```bash
157
+ claude mcp add npm-scripts npx -y npm-run-mcp-server
158
+ ```
152
159
 
160
+ #### Via Config File
161
+ Add to Claude Code's config file:
153
162
  - Windows: `%APPDATA%/Claude/claude_desktop_config.json`
154
163
  - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
155
164
  - Linux: `~/.config/Claude/claude_desktop_config.json`
156
165
 
157
- **Recommended approach** - Using npx (works across all projects):
158
-
159
166
  ```json
160
167
  {
161
168
  "mcpServers": {
@@ -167,48 +174,58 @@ Add this server to Claude's global config file (paths vary by OS). Create the fi
167
174
  }
168
175
  ```
169
176
 
170
- **Alternative** - Using a local build (requires absolute path):
177
+ Restart Claude Code after editing the config.
171
178
 
172
- ```json
173
- {
174
- "mcpServers": {
175
- "npm-scripts": {
176
- "command": "node",
177
- "args": ["/absolute/path/to/npm-run-mcp-server/dist/index.js"]
178
- }
179
- }
180
- }
181
- ```
179
+ ### Multi-Project Workflow
180
+
181
+ The MCP server automatically detects your project's `package.json` using workspace environment variables or by walking up from the current working directory. No hardcoded paths needed - it works seamlessly across all your projects.
182
+
183
+ ### Auto-Restart on Script Changes
182
184
 
183
- **Note**: The npx approach is recommended as it automatically targets the current working directory where Claude is launched.
185
+ The server automatically monitors your `package.json` file (and `npm-run-mcp.config.json` if present) for changes. When you modify scripts or config, the server gracefully exits to allow the MCP client to restart with updated tools.
184
186
 
185
- Optional: include environment variables
187
+ ### Script Exposure Config
188
+
189
+ You can make the tool surface more deterministic by explicitly choosing which scripts are exposed and by defining per-script tool metadata.
190
+
191
+ Create `npm-run-mcp.config.json` (or `.npm-run-mcp.json`) next to your project's `package.json`:
186
192
 
187
193
  ```json
188
194
  {
189
- "mcpServers": {
190
- "npm-scripts": {
191
- "command": "npx",
192
- "args": ["-y", "npm-run-mcp-server"],
193
- "env": {
194
- "NODE_ENV": "production"
195
+ "include": ["build", "lint", "test:unit"],
196
+ "exclude": ["dev", "test:e2e"],
197
+ "scripts": {
198
+ "test:unit": {
199
+ "toolName": "test_unit",
200
+ "description": "Run unit tests",
201
+ "inputSchema": {
202
+ "type": "object",
203
+ "properties": {
204
+ "watch": { "type": "boolean" },
205
+ "run": { "type": "boolean" }
206
+ }
207
+ }
208
+ },
209
+ "lint": {
210
+ "description": "Lint the codebase",
211
+ "inputSchema": {
212
+ "type": "object",
213
+ "properties": {
214
+ "fix": { "type": "boolean" }
215
+ }
195
216
  }
196
217
  }
197
218
  }
198
219
  }
199
220
  ```
200
221
 
201
- Restart Claude after editing the config so it picks up the new server.
202
-
203
- ### Install in Cursor
204
-
205
- - Open Settings MCP Servers Add MCP Server
206
- - Type: NPX Package
207
- - Command: `npx`
208
- - Arguments: `-y npm-run-mcp-server`
209
- - Save and start the server from the tools list
210
-
211
- **Note**: This configuration automatically works across all your projects. The server will target the current project's `package.json` wherever Cursor is opened.
222
+ Notes:
223
+ - `include` and `exclude` are exact script names.
224
+ - `toolName` lets you resolve naming collisions after sanitization.
225
+ - `inputSchema` extends the default input model (and `args` is always available).
226
+ - Tool input fields (other than `args`) are converted to CLI flags, e.g. `{ "watch": true }` becomes `--watch` and `{ "port": 3000 }` becomes `--port 3000`.
227
+ - If filters result in zero tools, the server logs a warning so misconfigurations are easy to spot.
228
+ - Config files support JSONC (comments + trailing commas). A JSON Schema is published as `npm-run-mcp.config.schema.json`.
212
229
 
213
230
  ### Install from source (for testing in another project)
214
231
 
@@ -255,7 +272,7 @@ Optional CLI flags you can pass in `args`:
255
272
 
256
273
  ## Testing with MCP Inspector
257
274
 
258
- Test the server locally before integrating with AI agents:
275
+ Test the server locally.
259
276
 
260
277
  ```bash
261
278
  # Start MCP Inspector
@@ -272,16 +289,17 @@ You should see your package.json scripts listed as available tools. Try running
272
289
 
273
290
  ## CLI Options
274
291
 
275
- Available command-line flags:
292
+ Command-line flags.
276
293
 
277
294
  - `--cwd <path>` - Specify working directory (defaults to current directory)
295
+ - `--config <path>` - Use an explicit config file path (relative to the project directory, or absolute)
278
296
  - `--pm <manager>` - Override package manager detection (npm|pnpm|yarn|bun)
279
297
  - `--verbose` - Enable detailed logging to stderr
280
298
  - `--list-scripts` - List available scripts and exit
281
299
 
282
300
  ## Contributing
283
301
 
284
- We welcome contributions! Here's how you can help:
302
+ Contributions welcome! How to help with development, reporting issues, and submitting changes.
285
303
 
286
304
  ### Reporting Issues
287
305
 
@@ -311,15 +329,7 @@ npm run test
311
329
 
312
330
  The project uses a custom build script located in `scripts/build.cjs` that handles TypeScript compilation and shebang injection for the executable.
313
331
 
314
- ### Guidelines
315
-
316
- - Follow the existing code style
317
- - Add tests for new features
318
- - Update documentation as needed
319
- - Keep commits focused and descriptive
320
332
 
321
333
  ## License
322
334
 
323
- MIT
324
-
325
-
335
+ MIT License.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
package/dist/index.js CHANGED
@@ -3,11 +3,192 @@ import { readFileSync, existsSync, watch } from 'fs';
3
3
  import { promises as fsp } from 'fs';
4
4
  import { dirname, resolve } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
- import { exec as nodeExec } from 'child_process';
7
- import { promisify } from 'util';
6
+ import spawn from 'cross-spawn';
7
+ import { parse as parseJsonc } from 'jsonc-parser';
8
8
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
9
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
- const exec = promisify(nodeExec);
10
+ import { z } from 'zod';
11
+ const MCP_CONFIG_FILES = ['npm-run-mcp.config.json', '.npm-run-mcp.json'];
12
+ function isPlainObject(value) {
13
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
14
+ }
15
+ function parseMcpConfig(raw) {
16
+ if (!isPlainObject(raw))
17
+ return {};
18
+ const include = Array.isArray(raw.include) ? raw.include.filter((v) => typeof v === 'string') : undefined;
19
+ const exclude = Array.isArray(raw.exclude) ? raw.exclude.filter((v) => typeof v === 'string') : undefined;
20
+ let scripts;
21
+ if (isPlainObject(raw.scripts)) {
22
+ scripts = {};
23
+ for (const [name, value] of Object.entries(raw.scripts)) {
24
+ if (!isPlainObject(value))
25
+ continue;
26
+ scripts[name] = {
27
+ toolName: typeof value.toolName === 'string' ? value.toolName : undefined,
28
+ description: typeof value.description === 'string' ? value.description : undefined,
29
+ inputSchema: isPlainObject(value.inputSchema) ? value.inputSchema : undefined,
30
+ argsDescription: typeof value.argsDescription === 'string' ? value.argsDescription : undefined,
31
+ };
32
+ }
33
+ }
34
+ return { include, exclude, scripts };
35
+ }
36
+ function parseJsonOrJsonc(text, filePathForErrors) {
37
+ try {
38
+ return JSON.parse(text);
39
+ }
40
+ catch {
41
+ const errors = [];
42
+ const parsed = parseJsonc(text, errors, { allowTrailingComma: true, disallowComments: false });
43
+ if (errors.length > 0) {
44
+ const formatted = errors
45
+ .slice(0, 3)
46
+ .map((e) => `code=${e.error} offset=${e.offset} length=${e.length}`)
47
+ .join(', ');
48
+ throw new Error(`Invalid JSON/JSONC in ${filePathForErrors} (${formatted})`);
49
+ }
50
+ return parsed;
51
+ }
52
+ }
53
+ async function readProjectMcpConfig(projectDir, verbose, configArg) {
54
+ if (typeof configArg === 'string' && configArg.length > 0) {
55
+ const candidate = resolve(projectDir, configArg);
56
+ if (!existsSync(candidate)) {
57
+ console.error(`npm-run-mcp-server: Config file not found: ${candidate}`);
58
+ process.exit(1);
59
+ }
60
+ try {
61
+ const raw = await fsp.readFile(candidate, 'utf8');
62
+ const parsed = parseJsonOrJsonc(raw, candidate);
63
+ const config = parseMcpConfig(parsed);
64
+ if (verbose) {
65
+ console.error(`[mcp] using config file: ${candidate}`);
66
+ }
67
+ return { config, configPath: candidate };
68
+ }
69
+ catch (error) {
70
+ const message = error?.message ? String(error.message) : String(error);
71
+ console.error(`npm-run-mcp-server: Failed to read config file ${candidate}: ${message}`);
72
+ process.exit(1);
73
+ }
74
+ }
75
+ for (const filename of MCP_CONFIG_FILES) {
76
+ const candidate = resolve(projectDir, filename);
77
+ if (!existsSync(candidate))
78
+ continue;
79
+ try {
80
+ const raw = await fsp.readFile(candidate, 'utf8');
81
+ const parsed = parseJsonOrJsonc(raw, candidate);
82
+ const config = parseMcpConfig(parsed);
83
+ if (verbose) {
84
+ console.error(`[mcp] using config file: ${candidate}`);
85
+ }
86
+ return { config, configPath: candidate };
87
+ }
88
+ catch (error) {
89
+ const message = error?.message ? String(error.message) : String(error);
90
+ console.error(`npm-run-mcp-server: Failed to read config file ${candidate}: ${message}`);
91
+ process.exit(1);
92
+ }
93
+ }
94
+ return { config: {}, configPath: null };
95
+ }
96
+ function normalizeToolName(name) {
97
+ const normalized = name.toLowerCase().replace(/[^a-z0-9_-]/g, '_');
98
+ return normalized.length > 0 ? normalized : 'script';
99
+ }
100
+ function jsonSchemaToZod(schema) {
101
+ if (!isPlainObject(schema))
102
+ return z.any();
103
+ if (Array.isArray(schema.enum) && schema.enum.every((v) => typeof v === 'string')) {
104
+ const values = schema.enum;
105
+ const [first, ...rest] = values;
106
+ let base = first ? z.literal(first) : z.string();
107
+ for (const v of rest)
108
+ base = z.union([base, z.literal(v)]);
109
+ return typeof schema.description === 'string' ? base.describe(schema.description) : base;
110
+ }
111
+ const type = schema.type;
112
+ let zod;
113
+ switch (type) {
114
+ case 'string':
115
+ zod = z.string();
116
+ break;
117
+ case 'boolean':
118
+ zod = z.boolean();
119
+ break;
120
+ case 'number':
121
+ zod = z.number();
122
+ break;
123
+ case 'integer':
124
+ zod = z.number().int();
125
+ break;
126
+ case 'array': {
127
+ const items = schema.items;
128
+ if (isPlainObject(items) && items.type === 'string')
129
+ zod = z.array(z.string());
130
+ else
131
+ zod = z.array(z.any());
132
+ break;
133
+ }
134
+ case 'object': {
135
+ const properties = isPlainObject(schema.properties) ? schema.properties : {};
136
+ const requiredSet = new Set(Array.isArray(schema.required) ? schema.required.filter((v) => typeof v === 'string') : []);
137
+ const shape = {};
138
+ for (const [key, value] of Object.entries(properties)) {
139
+ let prop = jsonSchemaToZod(value);
140
+ if (!requiredSet.has(key))
141
+ prop = prop.optional();
142
+ shape[key] = prop;
143
+ }
144
+ const obj = z.object(shape);
145
+ zod = schema.additionalProperties === false ? obj.strict() : obj.passthrough();
146
+ break;
147
+ }
148
+ default:
149
+ zod = z.any();
150
+ break;
151
+ }
152
+ if (typeof schema.description === 'string')
153
+ zod = zod.describe(schema.description);
154
+ return zod;
155
+ }
156
+ function buildToolInputSchema(configForScript) {
157
+ const base = z
158
+ .object({
159
+ _: z.array(z.string()).optional(),
160
+ args: z
161
+ .union([z.string(), z.array(z.string())])
162
+ .optional()
163
+ .describe(configForScript?.argsDescription ?? 'Optional arguments appended after -- to the script'),
164
+ })
165
+ .passthrough();
166
+ if (!configForScript?.inputSchema)
167
+ return base;
168
+ const converted = jsonSchemaToZod(configForScript.inputSchema);
169
+ if (converted instanceof z.ZodObject) {
170
+ const merged = converted.extend({
171
+ _: base.shape._,
172
+ args: base.shape.args,
173
+ });
174
+ return configForScript.inputSchema && isPlainObject(configForScript.inputSchema) && configForScript.inputSchema.additionalProperties === false
175
+ ? merged.strict()
176
+ : merged.passthrough();
177
+ }
178
+ return base;
179
+ }
180
+ function filterScriptNames(scriptNames, config) {
181
+ const include = config.include && config.include.length > 0 ? new Set(config.include) : null;
182
+ const exclude = config.exclude && config.exclude.length > 0 ? new Set(config.exclude) : null;
183
+ const filtered = scriptNames.filter((name) => {
184
+ if (include && !include.has(name))
185
+ return false;
186
+ if (exclude && exclude.has(name))
187
+ return false;
188
+ return true;
189
+ });
190
+ return filtered.sort();
191
+ }
11
192
  function parseCliArgs(argv) {
12
193
  const args = {};
13
194
  for (let i = 2; i < argv.length; i += 1) {
@@ -64,24 +245,150 @@ function detectPackageManager(projectDir, pkg, override) {
64
245
  return 'npm';
65
246
  }
66
247
  function buildRunCommand(pm, scriptName, extraArgs) {
67
- const quoted = scriptName.replace(/"/g, '\\"');
68
- const suffix = extraArgs && extraArgs.trim().length > 0 ? ` -- ${extraArgs}` : '';
69
- switch (pm) {
70
- case 'pnpm':
71
- return `pnpm run "${quoted}"${suffix}`;
72
- case 'yarn':
73
- return `yarn run "${quoted}"${suffix}`;
74
- case 'bun':
75
- return `bun run "${quoted}"${suffix}`;
76
- case 'npm':
77
- default:
78
- return `npm run "${quoted}"${suffix}`;
248
+ const command = pm;
249
+ const baseArgs = ['run', scriptName];
250
+ const args = extraArgs.length > 0 ? [...baseArgs, '--', ...extraArgs] : baseArgs;
251
+ return { command, args };
252
+ }
253
+ function parseArgString(input) {
254
+ const result = [];
255
+ let current = '';
256
+ let inSingle = false;
257
+ let inDouble = false;
258
+ let escaping = false;
259
+ let tokenActive = false;
260
+ const pushCurrent = () => {
261
+ if (!tokenActive)
262
+ return;
263
+ result.push(current);
264
+ current = '';
265
+ tokenActive = false;
266
+ };
267
+ for (let i = 0; i < input.length; i += 1) {
268
+ const ch = input[i];
269
+ if (escaping) {
270
+ current += ch;
271
+ escaping = false;
272
+ tokenActive = true;
273
+ continue;
274
+ }
275
+ if (!inSingle && ch === '\\') {
276
+ escaping = true;
277
+ tokenActive = true;
278
+ continue;
279
+ }
280
+ if (!inDouble && ch === "'" && !escaping) {
281
+ inSingle = !inSingle;
282
+ tokenActive = true;
283
+ continue;
284
+ }
285
+ if (!inSingle && ch === '"' && !escaping) {
286
+ inDouble = !inDouble;
287
+ tokenActive = true;
288
+ continue;
289
+ }
290
+ if (!inSingle && !inDouble && /\s/.test(ch)) {
291
+ pushCurrent();
292
+ continue;
293
+ }
294
+ current += ch;
295
+ tokenActive = true;
79
296
  }
297
+ if (escaping) {
298
+ current += '\\';
299
+ tokenActive = true;
300
+ }
301
+ pushCurrent();
302
+ return result;
80
303
  }
81
- function trimOutput(out, limit = 12000) {
82
- if (out.length <= limit)
304
+ function toolInputToExtraArgs(input) {
305
+ if (!isPlainObject(input))
306
+ return [];
307
+ const rawArgsValue = input.args;
308
+ let rawArgs = [];
309
+ if (typeof rawArgsValue === 'string') {
310
+ rawArgs = parseArgString(rawArgsValue);
311
+ }
312
+ else if (Array.isArray(rawArgsValue)) {
313
+ rawArgs = rawArgsValue.map((v) => String(v));
314
+ }
315
+ const positionalValue = input._;
316
+ const positional = Array.isArray(positionalValue) ? positionalValue.map((v) => String(v)) : [];
317
+ const keys = Object.keys(input)
318
+ .filter((k) => k !== 'args' && k !== '_' && input[k] !== undefined)
319
+ .sort();
320
+ const flags = [];
321
+ for (const key of keys) {
322
+ const value = input[key];
323
+ const flag = key.startsWith('-') ? key : `--${key}`;
324
+ if (value === null || value === undefined)
325
+ continue;
326
+ if (typeof value === 'boolean') {
327
+ if (value)
328
+ flags.push(flag);
329
+ continue;
330
+ }
331
+ if (Array.isArray(value)) {
332
+ for (const item of value) {
333
+ if (item === null || item === undefined)
334
+ continue;
335
+ if (typeof item === 'boolean') {
336
+ if (item)
337
+ flags.push(flag);
338
+ }
339
+ else {
340
+ flags.push(flag, String(item));
341
+ }
342
+ }
343
+ continue;
344
+ }
345
+ if (typeof value === 'object') {
346
+ flags.push(flag, JSON.stringify(value));
347
+ continue;
348
+ }
349
+ flags.push(flag, String(value));
350
+ }
351
+ return [...flags, ...positional, ...rawArgs];
352
+ }
353
+ function trimOutput(out, limit = 12000, totalLength) {
354
+ const total = typeof totalLength === 'number' ? totalLength : out.length;
355
+ if (total <= limit)
83
356
  return { text: out, truncated: false };
84
- return { text: out.slice(0, limit) + `\n...[truncated ${out.length - limit} chars]`, truncated: true };
357
+ return { text: out.slice(0, limit) + `\n...[truncated ${total - limit} chars]`, truncated: true };
358
+ }
359
+ async function runProcess(command, args, options) {
360
+ const outputCaptureLimit = 120000;
361
+ let stdout = '';
362
+ let stderr = '';
363
+ let stdoutTotal = 0;
364
+ let stderrTotal = 0;
365
+ const child = spawn(command, args, {
366
+ cwd: options.cwd,
367
+ env: options.env,
368
+ windowsHide: true,
369
+ stdio: ['ignore', 'pipe', 'pipe'],
370
+ });
371
+ const capture = (kind, chunk) => {
372
+ const text = chunk.toString('utf8');
373
+ if (kind === 'stdout') {
374
+ stdoutTotal += text.length;
375
+ if (stdout.length < outputCaptureLimit)
376
+ stdout += text.slice(0, outputCaptureLimit - stdout.length);
377
+ }
378
+ else {
379
+ stderrTotal += text.length;
380
+ if (stderr.length < outputCaptureLimit)
381
+ stderr += text.slice(0, outputCaptureLimit - stderr.length);
382
+ }
383
+ };
384
+ child.stdout?.on('data', (chunk) => capture('stdout', chunk));
385
+ child.stderr?.on('data', (chunk) => capture('stderr', chunk));
386
+ const exit = await new Promise((resolvePromise, rejectPromise) => {
387
+ child.on('error', (err) => rejectPromise(err));
388
+ child.on('close', (code, signal) => resolvePromise({ exitCode: code, signal }));
389
+ });
390
+ const totalLength = stdoutTotal + stderrTotal + (stdoutTotal > 0 && stderrTotal > 0 ? 1 : 0);
391
+ return { stdout, stderr, exitCode: exit.exitCode, signal: exit.signal, totalLength };
85
392
  }
86
393
  async function main() {
87
394
  const args = parseCliArgs(process.argv);
@@ -211,40 +518,69 @@ async function main() {
211
518
  if (scriptNames.length === 0) {
212
519
  console.error(`npm-run-mcp-server: No scripts found in ${pkgJsonPath}`);
213
520
  }
521
+ const { config: mcpConfig, configPath: mcpConfigPath } = await readProjectMcpConfig(projectDir, verbose, typeof args.config === 'string' ? String(args.config) : undefined);
522
+ const filteredScriptNames = filterScriptNames(scriptNames, mcpConfig);
523
+ if (filteredScriptNames.length === 0) {
524
+ const hint = mcpConfig.include?.length
525
+ ? 'Check your config "include"/"exclude" settings.'
526
+ : 'Check your package.json "scripts" section.';
527
+ console.error(`npm-run-mcp-server: No scripts selected for exposure. ${hint}`);
528
+ }
529
+ if (verbose && mcpConfig.include?.length) {
530
+ const missing = mcpConfig.include.filter((name) => !scripts[name]);
531
+ if (missing.length > 0) {
532
+ console.error(`[mcp] include list references missing scripts: ${missing.join(', ')}`);
533
+ }
534
+ }
535
+ const toolNameToScripts = new Map();
536
+ for (const scriptName of filteredScriptNames) {
537
+ const overrideName = mcpConfig.scripts?.[scriptName]?.toolName;
538
+ const toolName = normalizeToolName(overrideName ?? scriptName);
539
+ const existing = toolNameToScripts.get(toolName) ?? [];
540
+ existing.push(scriptName);
541
+ toolNameToScripts.set(toolName, existing);
542
+ }
543
+ const collisions = Array.from(toolNameToScripts.entries()).filter(([, names]) => names.length > 1);
544
+ if (collisions.length > 0) {
545
+ console.error('npm-run-mcp-server: Tool name collisions detected. Set "scripts.<name>.toolName" in npm-run-mcp.config.json to disambiguate.');
546
+ for (const [toolName, names] of collisions) {
547
+ console.error(` ${toolName}: ${names.join(', ')}`);
548
+ }
549
+ process.exit(1);
550
+ }
214
551
  if (args['list-scripts']) {
215
- for (const name of scriptNames) {
552
+ for (const name of filteredScriptNames) {
216
553
  console.error(`${name}: ${scripts[name]}`);
217
554
  }
218
555
  process.exit(0);
219
556
  }
220
557
  // Register a tool per script
221
- for (const scriptName of scriptNames) {
558
+ for (const scriptName of filteredScriptNames) {
222
559
  // Sanitize tool name - MCP tools can only contain [a-z0-9_-]
223
- const toolName = scriptName.toLowerCase().replace(/[^a-z0-9_-]/g, '_');
560
+ const configForScript = mcpConfig.scripts?.[scriptName];
561
+ const toolName = normalizeToolName(configForScript?.toolName ?? scriptName);
224
562
  // Create a more descriptive description
225
563
  const scriptCommand = scripts[scriptName];
226
- const description = `Run npm script "${scriptName}": ${scriptCommand}`;
227
- server.tool(toolName, description, {
228
- inputSchema: {
229
- type: 'object',
230
- properties: {
231
- args: {
232
- type: 'string',
233
- description: 'Optional arguments appended after -- to the script'
234
- }
235
- }
236
- },
237
- }, async ({ args: extraArgs }) => {
238
- const command = buildRunCommand(pm, scriptName, extraArgs);
564
+ const description = configForScript?.description ?? `Run npm script "${scriptName}": ${scriptCommand}`;
565
+ server.registerTool(toolName, {
566
+ description,
567
+ inputSchema: buildToolInputSchema(configForScript),
568
+ }, async (input) => {
569
+ const extraArgs = toolInputToExtraArgs(input);
570
+ const { command, args: runArgs } = buildRunCommand(pm, scriptName, extraArgs);
239
571
  try {
240
- const { stdout, stderr } = await exec(command, {
572
+ const { stdout, stderr, exitCode, signal, totalLength } = await runProcess(command, runArgs, {
241
573
  cwd: projectDir,
242
574
  env: process.env,
243
- maxBuffer: 16 * 1024 * 1024, // 16MB
244
- windowsHide: true,
245
575
  });
246
576
  const combined = stdout && stderr ? `${stdout}\n${stderr}` : stdout || stderr || '';
247
- const { text } = trimOutput(combined);
577
+ const succeeded = exitCode === 0;
578
+ const failurePrefix = succeeded
579
+ ? ''
580
+ : `Command failed (exit=${exitCode}${signal ? `, signal=${signal}` : ''}): ${command} ${runArgs.join(' ')}`;
581
+ const combinedWithStatus = failurePrefix ? [failurePrefix, combined].filter(Boolean).join('\n') : combined;
582
+ const totalLengthWithStatus = failurePrefix ? totalLength + failurePrefix.length + (combined ? 1 : 0) : totalLength;
583
+ const { text } = trimOutput(combinedWithStatus, 12000, totalLengthWithStatus);
248
584
  return {
249
585
  content: [
250
586
  {
@@ -255,11 +591,8 @@ async function main() {
255
591
  };
256
592
  }
257
593
  catch (error) {
258
- const stdout = error?.stdout ?? '';
259
- const stderr = error?.stderr ?? '';
260
594
  const message = error?.message ? String(error.message) : 'Script failed';
261
- const combined = [message, stdout, stderr].filter(Boolean).join('\n');
262
- const { text } = trimOutput(combined);
595
+ const { text } = trimOutput(message);
263
596
  return {
264
597
  content: [
265
598
  {
@@ -273,33 +606,43 @@ async function main() {
273
606
  }
274
607
  const transport = new StdioServerTransport();
275
608
  if (verbose) {
276
- console.error(`[mcp] registered ${scriptNames.length} tools; awaiting stdio client...`);
609
+ console.error(`[mcp] registered ${filteredScriptNames.length} tools; awaiting stdio client...`);
277
610
  }
278
611
  await server.connect(transport);
279
612
  if (verbose) {
280
613
  console.error(`[mcp] stdio transport connected (waiting for initialize)`);
281
614
  }
282
- // Set up file watcher for package.json changes
283
- if (pkgJsonPath) {
615
+ // Set up file watcher for package/config changes
616
+ const watchers = [];
617
+ const watchPath = (pathToWatch, label) => {
284
618
  if (verbose) {
285
- console.error(`[mcp] setting up file watcher for: ${pkgJsonPath}`);
619
+ console.error(`[mcp] setting up file watcher for ${label}: ${pathToWatch}`);
286
620
  }
287
- const watcher = watch(pkgJsonPath, (eventType) => {
288
- if (eventType === 'change') {
289
- if (verbose) {
290
- console.error(`[mcp] package.json changed, restarting server...`);
291
- }
292
- // Gracefully exit to allow the MCP client to restart the server
293
- process.exit(0);
621
+ watchers.push(watch(pathToWatch, (eventType) => {
622
+ if (eventType !== 'change')
623
+ return;
624
+ if (verbose) {
625
+ console.error(`[mcp] ${label} changed, restarting server...`);
294
626
  }
295
- });
296
- // Handle cleanup on process exit
627
+ // Gracefully exit to allow the MCP client to restart the server
628
+ process.exit(0);
629
+ }));
630
+ };
631
+ if (pkgJsonPath)
632
+ watchPath(pkgJsonPath, 'package.json');
633
+ if (mcpConfigPath)
634
+ watchPath(mcpConfigPath, 'config');
635
+ if (watchers.length > 0) {
636
+ const cleanup = () => {
637
+ for (const watcher of watchers)
638
+ watcher.close();
639
+ };
297
640
  process.on('SIGINT', () => {
298
- watcher.close();
641
+ cleanup();
299
642
  process.exit(0);
300
643
  });
301
644
  process.on('SIGTERM', () => {
302
- watcher.close();
645
+ cleanup();
303
646
  process.exit(0);
304
647
  });
305
648
  }
@@ -0,0 +1,46 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/fstubner/npm-run-mcp-server/main/npm-run-mcp.config.schema.json",
4
+ "title": "npm-run-mcp-server config",
5
+ "description": "Controls which package.json scripts are exposed as MCP tools, plus optional per-script metadata.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "include": {
10
+ "type": "array",
11
+ "items": { "type": "string" },
12
+ "description": "Exact script names to include. If omitted, all scripts are eligible (subject to exclude)."
13
+ },
14
+ "exclude": {
15
+ "type": "array",
16
+ "items": { "type": "string" },
17
+ "description": "Exact script names to exclude."
18
+ },
19
+ "scripts": {
20
+ "type": "object",
21
+ "description": "Per-script tool metadata overrides.",
22
+ "additionalProperties": {
23
+ "type": "object",
24
+ "additionalProperties": false,
25
+ "properties": {
26
+ "toolName": {
27
+ "type": "string",
28
+ "description": "Override the generated tool name (after sanitization)."
29
+ },
30
+ "description": {
31
+ "type": "string",
32
+ "description": "Override the tool description."
33
+ },
34
+ "argsDescription": {
35
+ "type": "string",
36
+ "description": "Description shown for the `args` input field."
37
+ },
38
+ "inputSchema": {
39
+ "type": "object",
40
+ "description": "JSON Schema for additional tool inputs; keys are converted to CLI flags. `args` is always available."
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "npm-run-mcp-server",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
+ "mcpName": "io.github.fstubner/npm-run-mcp-server",
4
5
  "description": "An MCP server that exposes package.json scripts as tools for agents.",
5
6
  "bin": {
6
7
  "npm-run-mcp-server": "dist/index.js"
@@ -11,19 +12,24 @@
11
12
  },
12
13
  "files": [
13
14
  "dist/index.js",
14
- "dist/index.d.ts"
15
+ "dist/index.d.ts",
16
+ "dist/index.d.ts.map",
17
+ "npm-run-mcp.config.schema.json"
15
18
  ],
16
19
  "scripts": {
17
20
  "build": "node scripts/build.cjs",
18
21
  "start": "node ./dist/index.js",
19
- "test": "node dist/index.js --list-scripts && echo 'MCP server test completed successfully'",
22
+ "test:integration": "node scripts/integration-test.mjs",
23
+ "test": "node dist/index.js --list-scripts && node scripts/integration-test.mjs && echo 'MCP server test completed successfully'",
20
24
  "prepublishOnly": "npm run build"
21
25
  },
22
26
  "engines": {
23
27
  "node": ">=18.18.0"
24
28
  },
25
29
  "dependencies": {
26
- "@modelcontextprotocol/sdk": "^1.0.0",
30
+ "@modelcontextprotocol/sdk": "^1.25.1",
31
+ "cross-spawn": "^7.0.5",
32
+ "jsonc-parser": "^3.3.1",
27
33
  "zod": "^3.23.8"
28
34
  },
29
35
  "bundledDependencies": [
@@ -31,7 +37,8 @@
31
37
  ],
32
38
  "devDependencies": {
33
39
  "typescript": "^5.4.0",
34
- "@types/node": "^20.14.9"
40
+ "@types/node": "^20.14.9",
41
+ "@types/cross-spawn": "^6.0.6"
35
42
  },
36
43
  "keywords": [
37
44
  "mcp",