@jam-mcp/server 1.4.2 → 1.4.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/README.md CHANGED
@@ -74,10 +74,10 @@ auth login Store Jira credentials in this user's OS secret store
74
74
  runtime Show or change which JAM build this machine runs
75
75
  ```
76
76
 
77
- Written out, that is `npx --yes @jam-mcp/launcher@1.4.2 doctor`, or just `jam
77
+ Written out, that is `npx --yes @jam-mcp/launcher@1.4.3 doctor`, or just `jam
78
78
  doctor` if you took the launcher's optional global install. Starting from
79
79
  nothing — no install, no runtime chosen yet — use
80
- `npx --yes @jam-mcp/bootstrap@1.4.2 init` instead.
80
+ `npx --yes @jam-mcp/bootstrap@1.4.3 init` instead.
81
81
 
82
82
  Credentials come from the process environment or this user's OS secret store —
83
83
  never from a repository file — and never appear in logs, telemetry, or tool
@@ -42,6 +42,16 @@ export type HostRunResult = {
42
42
  /** Injected by tests. Nothing in this module may reach a real CLI unasked. */
43
43
  export type HostRunner = (command: HostCommand) => HostRunResult;
44
44
  export declare const defaultHostRunner: HostRunner;
45
+ /**
46
+ * A runner that only sees what is persistently installed on this machine.
47
+ *
48
+ * Under `npx --yes @jam-mcp/bootstrap@X`, PATH carries npx's cache
49
+ * `node_modules/.bin` - which contains a `jam` shim that vanishes when npx
50
+ * exits. Any measurement of "does a global jam exist" through the normal PATH
51
+ * therefore lies during bootstrap, which is exactly when the answer matters
52
+ * most. This runner strips those entries first.
53
+ */
54
+ export declare const persistentHostRunner: HostRunner;
45
55
  export declare function hostRegistration(id: HostId, options?: {
46
56
  bare?: boolean;
47
57
  }): HostCommand | undefined;
@@ -80,6 +90,10 @@ export declare function isBareJamEntry(line: string): boolean;
80
90
  *
81
91
  * undefined when `jam` is not on PATH or does not answer: a registration that
82
92
  * cannot be measured counts as stale, same as an unreadable pin.
93
+ *
94
+ * Measured through persistentHostRunner by default: under an npx bootstrap
95
+ * the ordinary PATH resolves `jam` to npx's own ephemeral cache shim, and a
96
+ * bare registration decided on that evidence dies as soon as npx exits.
83
97
  */
84
98
  export declare function bareJamVersion(run?: HostRunner): string | undefined;
85
99
  /** Is this measured launcher version the one this release registers? */
@@ -1,18 +1,43 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { JAM_MCP_ENTRY } from "./mcp-config-merger.js";
3
+ import { shellInvocation, stripPackageRunnerPath } from "./shell-command.js";
3
4
  /**
4
5
  * These boot a whole Node CLI, and Claude Code health-checks every configured
5
6
  * server while listing, which is seconds rather than milliseconds.
6
7
  */
7
8
  const HOST_TIMEOUT_MS = 20_000;
8
9
  export const defaultHostRunner = ({ command, args }) => {
9
- const result = spawnSync(command, args, {
10
+ // Both CLIs are npm shims on Windows, and Node refuses to spawn a .cmd
11
+ // without a shell. Every argument JAM passes is a bare token - no JSON, no
12
+ // spaces - and shellInvocation validates exactly that before joining the
13
+ // argv into one line (an args array plus shell:true is DEP0190).
14
+ const invocation = shellInvocation(command, args);
15
+ const result = spawnSync(invocation.command, invocation.args, {
10
16
  encoding: "utf8",
11
17
  timeout: HOST_TIMEOUT_MS,
12
- // Both CLIs are npm shims on Windows, and Node refuses to spawn a .cmd
13
- // without a shell. Every argument JAM passes is a bare token - no JSON, no
14
- // spaces - precisely so this cannot become a quoting hazard.
15
- shell: process.platform === "win32",
18
+ shell: invocation.shell,
19
+ });
20
+ if (result.error)
21
+ return { status: null, failed: true, stdout: "" };
22
+ return { status: result.status, failed: false, stdout: result.stdout ?? "" };
23
+ };
24
+ /**
25
+ * A runner that only sees what is persistently installed on this machine.
26
+ *
27
+ * Under `npx --yes @jam-mcp/bootstrap@X`, PATH carries npx's cache
28
+ * `node_modules/.bin` - which contains a `jam` shim that vanishes when npx
29
+ * exits. Any measurement of "does a global jam exist" through the normal PATH
30
+ * therefore lies during bootstrap, which is exactly when the answer matters
31
+ * most. This runner strips those entries first.
32
+ */
33
+ export const persistentHostRunner = ({ command, args }) => {
34
+ const invocation = shellInvocation(command, args);
35
+ const pathValue = process.env.PATH ?? process.env.Path ?? "";
36
+ const result = spawnSync(invocation.command, invocation.args, {
37
+ encoding: "utf8",
38
+ timeout: HOST_TIMEOUT_MS,
39
+ shell: invocation.shell,
40
+ env: { ...process.env, PATH: stripPackageRunnerPath(pathValue) },
16
41
  });
17
42
  if (result.error)
18
43
  return { status: null, failed: true, stdout: "" };
@@ -106,8 +131,12 @@ export function isBareJamEntry(line) {
106
131
  *
107
132
  * undefined when `jam` is not on PATH or does not answer: a registration that
108
133
  * cannot be measured counts as stale, same as an unreadable pin.
134
+ *
135
+ * Measured through persistentHostRunner by default: under an npx bootstrap
136
+ * the ordinary PATH resolves `jam` to npx's own ephemeral cache shim, and a
137
+ * bare registration decided on that evidence dies as soon as npx exits.
109
138
  */
110
- export function bareJamVersion(run = defaultHostRunner) {
139
+ export function bareJamVersion(run = persistentHostRunner) {
111
140
  const result = run({ command: "jam", args: ["runtime", "status", "--json"] });
112
141
  if (result.failed || result.status !== 0)
113
142
  return undefined;
@@ -187,7 +216,10 @@ function probeHosts(run) {
187
216
  };
188
217
  });
189
218
  function measuredBare() {
190
- bareCache ??= { version: bareJamVersion(run) };
219
+ // The real probe runner sees npx's contaminated PATH; the persistent
220
+ // runner is the honest one for this question. An injected runner is a
221
+ // test, and stays in charge of its own answers.
222
+ bareCache ??= { version: bareJamVersion(run === defaultHostRunner ? persistentHostRunner : run) };
191
223
  return bareCache.version;
192
224
  }
193
225
  }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { shellInvocation } from "./shell-command.js";
2
3
  import { SERVER_VERSION } from "@jam-mcp/launcher";
3
4
  import { TOOL_NAMES } from "../mcp/create-server.js";
4
5
  const HANDSHAKE_TIMEOUT_MS = 30_000;
@@ -9,9 +10,12 @@ export const expectedTools = () => [...TOOL_NAMES].sort();
9
10
  * one question that is three lines of JSON.
10
11
  */
11
12
  export const defaultToolsetProbe = ({ command, args }) => new Promise((resolve) => {
12
- const child = spawn(command, args, {
13
+ // npm shims need a shell on Windows; the argv is joined by shellInvocation
14
+ // (validated bare tokens) because an args array plus shell:true is DEP0190.
15
+ const invocation = shellInvocation(command, args);
16
+ const child = spawn(invocation.command, invocation.args, {
13
17
  stdio: ["pipe", "pipe", "ignore"],
14
- shell: process.platform === "win32",
18
+ shell: invocation.shell,
15
19
  });
16
20
  let buffer = "";
17
21
  let settled = false;
@@ -13,7 +13,7 @@ import { LAUNCHER_PACKAGE_SPEC } from "@jam-mcp/launcher";
13
13
  export { LAUNCHER_PACKAGE_SPEC };
14
14
  export declare const JAM_MCP_ENTRY: {
15
15
  readonly command: "npx";
16
- readonly args: readonly ["--yes", "@jam-mcp/launcher@1.4.2", "serve"];
16
+ readonly args: readonly ["--yes", "@jam-mcp/launcher@1.4.3", "serve"];
17
17
  };
18
18
  /**
19
19
  * Recognise wiring from before the launcher existed: a hard-coded `node` path
@@ -1,13 +1,16 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { shellInvocation } from "./shell-command.js";
2
3
  import { LAUNCHER_PACKAGE_SPEC } from "./mcp-config-merger.js";
3
4
  import { computeSetupPlan } from "./setup-plan.js";
4
5
  const PROBE_TIMEOUT_MS = 10_000;
5
6
  function runNpm(command, args) {
6
- const result = spawnSync(command, args, {
7
+ // npm on Windows is a shell script, not an executable; shellInvocation
8
+ // joins the validated argv because an args array plus shell:true is DEP0190.
9
+ const invocation = shellInvocation(command, args);
10
+ const result = spawnSync(invocation.command, invocation.args, {
7
11
  encoding: "utf8",
8
12
  timeout: PROBE_TIMEOUT_MS,
9
- // npm on Windows is a shell script, not an executable.
10
- shell: process.platform === "win32",
13
+ shell: invocation.shell,
11
14
  });
12
15
  return {
13
16
  status: result.status,
@@ -0,0 +1,36 @@
1
+ /**
2
+ * How to hand an npm-shim CLI to spawnSync without tripping DEP0190.
3
+ *
4
+ * npm-installed CLIs (`claude`, `codex`, `npm`, `npx`, `jam`) are `.cmd`
5
+ * shims on Windows, and Node only runs those through a shell. But passing an
6
+ * args ARRAY together with `shell: true` is deprecated (DEP0190): Node
7
+ * concatenates the arguments without escaping, so the array form is an
8
+ * illusion of safety. On Windows the argv is therefore joined into a single
9
+ * command line HERE, deliberately - and every token is validated bare first,
10
+ * so the join cannot become a quoting hazard. A token that would need cmd.exe
11
+ * quoting is refused outright rather than escaped: nothing JAM runs ever
12
+ * carries one, so meeting one means the input is not ours to guess about.
13
+ *
14
+ * On every other platform the array form without a shell is correct and
15
+ * unchanged.
16
+ */
17
+ export type ShellInvocation = {
18
+ command: string;
19
+ args: string[];
20
+ shell: boolean;
21
+ };
22
+ export declare function shellInvocation(command: string, args: readonly string[], platform?: NodeJS.Platform): ShellInvocation;
23
+ /**
24
+ * PATH with package-runner injections removed.
25
+ *
26
+ * `npx --yes @jam-mcp/bootstrap@X` prepends its cache's `node_modules/.bin`
27
+ * to PATH, and that directory contains a `jam` shim - so inside a bootstrap
28
+ * run, `jam` resolves even on a machine where nothing is installed. Measuring
29
+ * "does this machine have a persistent jam" through that PATH answered yes on
30
+ * every fresh machine, and setup then registered a bare `jam serve` that died
31
+ * the moment npx's directory evaporated (the v1.4.2 fresh-install field
32
+ * failure). A persistent install lives in npm's global bin, never under a
33
+ * `node_modules` or `_npx` directory, so those entries are dropped before the
34
+ * measurement.
35
+ */
36
+ export declare function stripPackageRunnerPath(pathValue: string, separator?: string): string;
@@ -0,0 +1,31 @@
1
+ import { delimiter } from "node:path";
2
+ /** Anything cmd.exe would re-interpret, plus whitespace and quotes. */
3
+ const CMD_UNSAFE = /[\s&|<>^"'`;()]/;
4
+ export function shellInvocation(command, args, platform = process.platform) {
5
+ if (platform !== "win32")
6
+ return { command, args: [...args], shell: false };
7
+ const unsafe = [command, ...args].find((token) => token === "" || CMD_UNSAFE.test(token));
8
+ if (unsafe !== undefined) {
9
+ throw new Error(`refusing to pass a token through cmd.exe unquoted: ${JSON.stringify(unsafe)}`);
10
+ }
11
+ return { command: [command, ...args].join(" "), args: [], shell: true };
12
+ }
13
+ /**
14
+ * PATH with package-runner injections removed.
15
+ *
16
+ * `npx --yes @jam-mcp/bootstrap@X` prepends its cache's `node_modules/.bin`
17
+ * to PATH, and that directory contains a `jam` shim - so inside a bootstrap
18
+ * run, `jam` resolves even on a machine where nothing is installed. Measuring
19
+ * "does this machine have a persistent jam" through that PATH answered yes on
20
+ * every fresh machine, and setup then registered a bare `jam serve` that died
21
+ * the moment npx's directory evaporated (the v1.4.2 fresh-install field
22
+ * failure). A persistent install lives in npm's global bin, never under a
23
+ * `node_modules` or `_npx` directory, so those entries are dropped before the
24
+ * measurement.
25
+ */
26
+ export function stripPackageRunnerPath(pathValue, separator = delimiter) {
27
+ return pathValue
28
+ .split(separator)
29
+ .filter((entry) => !/[\\/]node_modules[\\/]|[\\/]_npx[\\/]/.test(`${entry}/`.replace(/[\\/]+$/, "/")))
30
+ .join(separator);
31
+ }
@@ -69,11 +69,23 @@ export async function setupApplyCommand(options = {}) {
69
69
  ...(options.home ? { home: options.home } : {}),
70
70
  ...(options.runHost ? { runHost: options.runHost } : {}),
71
71
  });
72
- emitJson({ ...plan, status: applyStatus(plan), changesApplied: result.changesApplied });
72
+ emitJson({
73
+ ...plan,
74
+ status: applyStatus(plan, result.changesApplied),
75
+ changesApplied: result.changesApplied,
76
+ });
73
77
  return plan.requiresUserAction ? 1 : 0;
74
78
  }
75
- function applyStatus(plan) {
76
- return plan.requiresUserAction ? "user_action_required" : "already_configured";
79
+ /**
80
+ * The status of an apply, after it ran. "already_configured" means nothing
81
+ * was executed; when changes did run the answer is "applied" - reporting
82
+ * "already_configured" alongside changesApplied:true made the two fields
83
+ * contradict each other, and an agent could believe either one.
84
+ */
85
+ function applyStatus(plan, changesApplied) {
86
+ if (plan.requiresUserAction)
87
+ return "user_action_required";
88
+ return changesApplied ? "applied" : "already_configured";
77
89
  }
78
90
  /**
79
91
  * `jam setup --agent` - one shot: detect, plan, apply what is safe, verify.
package/dist/cli/setup.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { shellInvocation } from "../bootstrap/shell-command.js";
2
3
  import { existsSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { runHealthGate } from "../bootstrap/boot-health-gate.js";
@@ -178,10 +179,13 @@ async function installAndBuild(root) {
178
179
  { name: "Build", args: ["run", "build"] },
179
180
  ]) {
180
181
  line(`\n> npm ${step.args.join(" ")}`);
181
- const res = spawnSync("npm", step.args, {
182
+ // npm is a .cmd shim on Windows; shellInvocation joins the validated argv
183
+ // because an args array plus shell:true is DEP0190.
184
+ const invocation = shellInvocation("npm", step.args);
185
+ const res = spawnSync(invocation.command, invocation.args, {
182
186
  cwd: root,
183
187
  stdio: "inherit",
184
- shell: process.platform === "win32",
188
+ shell: invocation.shell,
185
189
  });
186
190
  if (res.status !== 0) {
187
191
  line(`[FAIL] ${step.name}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jam-mcp/server",
3
- "version": "1.4.2",
3
+ "version": "1.4.3",
4
4
  "description": "JAM (Jira Agent MCP) - agent-facing Jira access layer: MCP server, setup core, and CLI",
5
5
  "keywords": [
6
6
  "jira",
@@ -41,7 +41,7 @@
41
41
  "test:watch": "vitest"
42
42
  },
43
43
  "dependencies": {
44
- "@jam-mcp/launcher": "1.4.2",
44
+ "@jam-mcp/launcher": "1.4.3",
45
45
  "@modelcontextprotocol/sdk": "^1.30.0",
46
46
  "yaml": "^2.9.0",
47
47
  "zod": "^4.4.3"