@jam-mcp/server 1.4.6 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.6 doctor`, or just `jam
77
+ Written out, that is `npx --yes @jam-mcp/launcher@1.5.0 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.6 init` instead.
80
+ `npx --yes @jam-mcp/bootstrap@1.5.0 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
@@ -54,6 +54,7 @@ export declare const defaultHostRunner: HostRunner;
54
54
  export declare const persistentHostRunner: HostRunner;
55
55
  export declare function hostRegistration(id: HostId, options?: {
56
56
  bare?: boolean;
57
+ version?: string;
57
58
  }): HostCommand | undefined;
58
59
  /** The removal that has to precede re-registering an entry this host already has. */
59
60
  export declare function hostUnregistration(id: HostId): HostCommand | undefined;
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { JAM_MCP_ENTRY } from "./mcp-config-merger.js";
2
+ import { LAUNCHER_PACKAGE } from "@jam-mcp/launcher";
3
+ import { JAM_MCP_ENTRY, LAUNCHER_PACKAGE_SPEC } from "./mcp-config-merger.js";
3
4
  import { shellInvocation, stripPackageRunnerPath } from "./shell-command.js";
4
5
  /**
5
6
  * These boot a whole Node CLI, and Claude Code health-checks every configured
@@ -75,8 +76,18 @@ export function hostRegistration(id, options = {}) {
75
76
  const adapter = ADAPTERS.find((a) => a.id === id);
76
77
  if (!adapter)
77
78
  return undefined;
78
- if (!options.bare)
79
- return adapter.register;
79
+ if (!options.bare) {
80
+ // `jam update` registers a version this build is not - that is the whole
81
+ // point of an update. Only the pin moves; the rest of the argv is the same
82
+ // line setup writes, so the two cannot drift into different registrations.
83
+ if (!options.version)
84
+ return adapter.register;
85
+ const pinned = `${LAUNCHER_PACKAGE}@${options.version}`;
86
+ return {
87
+ command: adapter.register.command,
88
+ args: adapter.register.args.map((arg) => (arg === LAUNCHER_PACKAGE_SPEC ? pinned : arg)),
89
+ };
90
+ }
80
91
  const at = adapter.register.args.indexOf("--");
81
92
  return { command: adapter.register.command, args: [...adapter.register.args.slice(0, at), ...LAUNCH_BARE] };
82
93
  }
@@ -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.6", "serve"];
16
+ readonly args: readonly ["--yes", "@jam-mcp/launcher@1.5.0", "serve"];
17
17
  };
18
18
  /**
19
19
  * Recognise wiring from before the launcher existed: a hard-coded `node` path
@@ -0,0 +1,74 @@
1
+ import { type HostId, type HostRunner } from "../bootstrap/host-mcp.js";
2
+ /**
3
+ * `jam update` - move this machine to the published release without redoing setup.
4
+ *
5
+ * What actually goes stale here is not a directory. JAM is launched by the
6
+ * host from a registration line, and that line carries an exact pin:
7
+ *
8
+ * jam: npx --yes @jam-mcp/launcher@1.4.5 serve
9
+ *
10
+ * A newer JAM on the registry changes nothing until that line moves. Right
11
+ * after 1.4.6 was published, this machine measured
12
+ * `registration: HOST_REGISTRATION_STALE (registered 1.4.5)` - the agent was
13
+ * still talking to the previous server. The command that fixed it was
14
+ * `jam setup --agent`, which also binds the project, re-detects credentials
15
+ * and re-plans everything else. Re-running setup is not what "update" means,
16
+ * so this path never calls it.
17
+ *
18
+ * The order is the same one ASC uses, and for the same reason: the new build
19
+ * is proven to answer *before* the registration is pointed at it. Nothing is
20
+ * removed first - a failed update leaves the machine running what it was
21
+ * running.
22
+ */
23
+ export type UpdateStep = "install" | "verify-install" | "switch-registration" | "verify-health";
24
+ export declare const UPDATE_ORDER: readonly UpdateStep[];
25
+ export type UpdateState =
26
+ /** Every registration this machine has runs the published release. */
27
+ "CURRENT" | "UPDATE_AVAILABLE"
28
+ /** A registration exists whose version cannot be read - it may run anything. */
29
+ | "BROKEN"
30
+ /** The registry could not be asked. Nothing is claimed about being up to date. */
31
+ | "UNKNOWN";
32
+ export type HostPlan = {
33
+ id: HostId;
34
+ from?: string;
35
+ /** A bare `jam serve` entry: the version lives in the global install, not the line. */
36
+ bare: boolean;
37
+ action: "repin" | "none" | "unreadable";
38
+ };
39
+ export type JamUpdatePlan = {
40
+ state: UpdateState;
41
+ latest?: string;
42
+ running: string;
43
+ hosts: HostPlan[];
44
+ steps: readonly UpdateStep[];
45
+ detail?: string;
46
+ };
47
+ export type HostFacts = {
48
+ id: HostId;
49
+ cliAvailable: boolean;
50
+ hasJamEntry: boolean;
51
+ entryVersion?: string;
52
+ entryBare?: boolean;
53
+ };
54
+ /**
55
+ * Decide, from what was measured. Pure - it runs nothing and writes nothing.
56
+ *
57
+ * A host with no `jam` entry is left alone. Registering JAM somewhere it was
58
+ * never registered is adoption, not an update, and `jam setup` is where the
59
+ * person says they want that.
60
+ */
61
+ export declare function planJamUpdate(input: {
62
+ latest?: string;
63
+ hosts: HostFacts[];
64
+ }): JamUpdatePlan;
65
+ export declare function updateLine(plan: JamUpdatePlan): string;
66
+ export type UpdateOptions = {
67
+ json?: boolean;
68
+ /** Injected by tests. Nothing here may reach a real host CLI or npm unasked. */
69
+ run?: HostRunner;
70
+ /** Injected by tests so a plan never depends on the registry. */
71
+ latest?: () => string | undefined;
72
+ hosts?: () => HostFacts[];
73
+ };
74
+ export declare function jamUpdateCommand(command: string | undefined, options?: UpdateOptions): Promise<number>;
@@ -0,0 +1,233 @@
1
+ import { LAUNCHER_PACKAGE, SERVER_VERSION } from "@jam-mcp/launcher";
2
+ import { spawnSync } from "node:child_process";
3
+ import { detectHosts, hostRegistration, hostUnregistration, } from "../bootstrap/host-mcp.js";
4
+ export const UPDATE_ORDER = [
5
+ "install",
6
+ "verify-install",
7
+ "switch-registration",
8
+ "verify-health",
9
+ ];
10
+ /**
11
+ * Decide, from what was measured. Pure - it runs nothing and writes nothing.
12
+ *
13
+ * A host with no `jam` entry is left alone. Registering JAM somewhere it was
14
+ * never registered is adoption, not an update, and `jam setup` is where the
15
+ * person says they want that.
16
+ */
17
+ export function planJamUpdate(input) {
18
+ const running = SERVER_VERSION;
19
+ const registered = input.hosts.filter((host) => host.cliAvailable && host.hasJamEntry);
20
+ if (!input.latest) {
21
+ return {
22
+ state: "UNKNOWN",
23
+ running,
24
+ hosts: [],
25
+ steps: [],
26
+ detail: "the registry could not be asked - nothing is claimed about being up to date",
27
+ };
28
+ }
29
+ const hosts = registered.map((host) => ({
30
+ id: host.id,
31
+ ...(host.entryVersion ? { from: host.entryVersion } : {}),
32
+ bare: host.entryBare === true,
33
+ action: host.entryVersion === undefined
34
+ ? "unreadable"
35
+ : host.entryVersion === input.latest
36
+ ? "none"
37
+ : "repin",
38
+ }));
39
+ // An entry whose version cannot be read is not "current" - it is a line
40
+ // running something nobody measured. Say that instead of moving it silently.
41
+ if (hosts.some((host) => host.action === "unreadable")) {
42
+ return {
43
+ state: "BROKEN",
44
+ latest: input.latest,
45
+ running,
46
+ hosts,
47
+ steps: [...UPDATE_ORDER],
48
+ detail: "a registration exists whose version could not be read",
49
+ };
50
+ }
51
+ if (hosts.every((host) => host.action === "none")) {
52
+ return { state: "CURRENT", latest: input.latest, running, hosts, steps: [] };
53
+ }
54
+ return { state: "UPDATE_AVAILABLE", latest: input.latest, running, hosts, steps: [...UPDATE_ORDER] };
55
+ }
56
+ export function updateLine(plan) {
57
+ switch (plan.state) {
58
+ case "CURRENT":
59
+ return `Up to date - JAM ${plan.latest} is registered.`;
60
+ case "UPDATE_AVAILABLE": {
61
+ const moving = plan.hosts.filter((host) => host.action === "repin");
62
+ return `Update available - ${plan.latest}: ${moving
63
+ .map((host) => `${host.id} runs ${host.from}`)
64
+ .join(", ")}`;
65
+ }
66
+ case "BROKEN":
67
+ case "UNKNOWN":
68
+ return `${plan.state}: ${plan.detail ?? "(no detail)"}`;
69
+ }
70
+ }
71
+ /** A process runner with room for an install. The host runner's 20s is not enough for npm. */
72
+ const defaultRunner = ({ command, args }) => {
73
+ const result = spawnSync(command, args, {
74
+ encoding: "utf8",
75
+ timeout: 180_000,
76
+ shell: process.platform === "win32",
77
+ });
78
+ if (result.error)
79
+ return { status: null, failed: true, stdout: "" };
80
+ return { status: result.status, failed: false, stdout: result.stdout ?? "" };
81
+ };
82
+ export async function jamUpdateCommand(command, options = {}) {
83
+ if (command !== undefined && command !== "check" && command !== "plan") {
84
+ process.stderr.write(`Unknown update command: ${command}\nUsage: jam update [check|plan] [--json]\n`);
85
+ return 1;
86
+ }
87
+ const run = options.run ?? defaultRunner;
88
+ const latest = (options.latest ?? (() => registryLatest(run)))();
89
+ const facts = (options.hosts ?? (() => detectHosts(run)))();
90
+ const plan = planJamUpdate({ ...(latest ? { latest } : {}), hosts: facts });
91
+ if (command === "check" || command === "plan") {
92
+ emit(plan, options);
93
+ return 0;
94
+ }
95
+ if (plan.steps.length === 0) {
96
+ emit(plan, options);
97
+ return plan.state === "CURRENT" ? 0 : 1;
98
+ }
99
+ return apply(plan, run, options);
100
+ }
101
+ function emit(plan, options) {
102
+ if (options.json)
103
+ process.stdout.write(`${JSON.stringify({ package: LAUNCHER_PACKAGE, ...plan }, null, 2)}\n`);
104
+ else
105
+ process.stdout.write(`${updateLine(plan)}\n`);
106
+ }
107
+ /** What the registry has. undefined when it could not be asked - never a guess. */
108
+ function registryLatest(run) {
109
+ const result = run({ command: "npm", args: ["view", LAUNCHER_PACKAGE, "version"] });
110
+ if (result.failed || result.status !== 0)
111
+ return undefined;
112
+ const version = result.stdout.trim();
113
+ return /^\d+\.\d+\.\d+/.test(version) ? version : undefined;
114
+ }
115
+ /**
116
+ * Does that version actually answer?
117
+ *
118
+ * `runtime status --json` is the cheapest honest question: it resolves the
119
+ * runtime the registered entry would resolve, and needs no bound project. A
120
+ * pin that cannot answer here is one the host would fail to start - which is
121
+ * exactly what must not be registered.
122
+ */
123
+ function launcherAnswers(run, version, bare) {
124
+ const result = bare
125
+ ? run({ command: "jam", args: ["runtime", "status", "--json"] })
126
+ : run({ command: "npx", args: ["--yes", `${LAUNCHER_PACKAGE}@${version}`, "runtime", "status", "--json"] });
127
+ if (result.failed || result.status !== 0)
128
+ return undefined;
129
+ try {
130
+ const parsed = JSON.parse(result.stdout);
131
+ return typeof parsed.version === "string" ? parsed.version : undefined;
132
+ }
133
+ catch {
134
+ return undefined;
135
+ }
136
+ }
137
+ async function apply(plan, run, options) {
138
+ const target = plan.latest;
139
+ process.stdout.write(`${updateLine(plan)}\n`);
140
+ const moving = plan.hosts.filter((host) => host.action !== "none");
141
+ // A bare entry runs the global executable, so that is what has to move.
142
+ // An npx pin is fetched at launch; verifying it is what "install" means there.
143
+ if (moving.some((host) => host.bare)) {
144
+ const installed = run({ command: "npm", args: ["install", "-g", `${LAUNCHER_PACKAGE}@${target}`] });
145
+ if (installed.failed || installed.status !== 0) {
146
+ process.stderr.write(`install failed: npm install -g ${LAUNCHER_PACKAGE}@${target}\n`);
147
+ // Nothing was re-registered. The machine still runs what it ran.
148
+ return 1;
149
+ }
150
+ }
151
+ for (const host of moving) {
152
+ const answered = launcherAnswers(run, target, host.bare);
153
+ if (answered !== target) {
154
+ process.stderr.write(`verify failed: ${target} did not answer as ${target}${answered ? ` (got ${answered})` : ""} - registration left as it is\n`);
155
+ return 1;
156
+ }
157
+ }
158
+ process.stdout.write(`verified: ${LAUNCHER_PACKAGE}@${target} answers\n`);
159
+ for (const host of moving) {
160
+ // `mcp add` over an existing entry changes nothing on Claude Code - it
161
+ // answers "already exists". The removal is what makes the re-pin land.
162
+ if (!host.bare) {
163
+ const remove = hostUnregistration(host.id);
164
+ if (remove)
165
+ run(remove);
166
+ }
167
+ const register = hostRegistration(host.id, { ...(host.bare ? { bare: true } : { version: target }) });
168
+ if (!register)
169
+ continue;
170
+ const result = run(register);
171
+ if (result.failed || result.status !== 0) {
172
+ process.stderr.write(`switch failed on ${host.id}\n`);
173
+ return rollback(plan, host, run);
174
+ }
175
+ process.stdout.write(`registered: ${host.id} -> ${target}\n`);
176
+ }
177
+ // Read it back. A registration JAM could not verify is never reported as done.
178
+ const after = (options.hosts ?? (() => detectHosts(run)))();
179
+ const stale = after.filter((host) => moving.some((m) => m.id === host.id) && host.entryVersion !== target);
180
+ if (stale.length > 0) {
181
+ process.stderr.write(`health: ${stale.map((host) => `${host.id} still runs ${host.entryVersion ?? "?"}`).join(", ")}\n`);
182
+ return 1;
183
+ }
184
+ // A registration that points at a build which cannot read Jira is not a
185
+ // finished update. `doctor` is the existing health axis - config, credentials
186
+ // and one live read - so it is asked here rather than reimplemented.
187
+ const health = doctorVerdict(run, target, moving.some((host) => host.bare));
188
+ if (health !== "ready") {
189
+ process.stderr.write(`doctor: ${health} - rolling back\n`);
190
+ const first = moving[0];
191
+ return first ? rollback(plan, first, run) : 1;
192
+ }
193
+ process.stdout.write(`JAM ${target} is registered, doctor ready.\n`);
194
+ return 0;
195
+ }
196
+ /**
197
+ * What `jam doctor --json` says about the build now registered.
198
+ *
199
+ * Run through the same entry the host would run, so this measures the thing
200
+ * that was just registered rather than the process doing the registering.
201
+ */
202
+ function doctorVerdict(run, version, bare) {
203
+ const result = bare
204
+ ? run({ command: "jam", args: ["doctor", "--json"] })
205
+ : run({ command: "npx", args: ["--yes", `${LAUNCHER_PACKAGE}@${version}`, "doctor", "--json"] });
206
+ if (result.failed)
207
+ return "could not be run";
208
+ try {
209
+ const parsed = JSON.parse(result.stdout);
210
+ return typeof parsed.status === "string" ? parsed.status : "unreadable";
211
+ }
212
+ catch {
213
+ return result.status === 0 ? "ready" : "unreadable";
214
+ }
215
+ }
216
+ /** Put back the pin that was there. Only possible when it was readable. */
217
+ function rollback(plan, host, run) {
218
+ if (!host.from || host.bare) {
219
+ process.stderr.write(`Nothing to roll back to on ${host.id} - re-register with \`jam setup --agent\`.\n`);
220
+ return 1;
221
+ }
222
+ const remove = hostUnregistration(host.id);
223
+ if (remove)
224
+ run(remove);
225
+ const back = hostRegistration(host.id, { version: host.from });
226
+ const result = back ? run(back) : undefined;
227
+ if (result && !result.failed && result.status === 0) {
228
+ process.stderr.write(`rolled back - ${host.id} runs ${host.from} again.\n`);
229
+ return 1;
230
+ }
231
+ process.stderr.write(`rollback failed on ${host.id} - re-register with \`jam setup --agent\`.\n`);
232
+ return 1;
233
+ }
@@ -3,5 +3,5 @@
3
3
  * points (notably @jam-mcp/bootstrap) can forward to exactly these commands
4
4
  * instead of reimplementing them.
5
5
  */
6
- export declare const USAGE = "jam - Jira Agent MCP\n\nUsage:\n jam serve Run the MCP server over stdio (default; this is what Claude Code / Codex launch)\n jam doctor Diagnose config, credentials and Jira connectivity\n jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]\n Wire up this project and run doctor. Binds it to you\n alone, writing nothing to the repository; --shared\n adopts JAM for the team (project.yaml, .mcp.json)\n jam runtime Show which JAM build this machine runs\n jam runtime use package | development <path>\n Change it (writes ~/.jam/config.yaml only, never a project)\n jam auth login Store Jira credentials in this user's OS secret store\n jam auth logout Remove them again\n\nFor coding agents and scripts (stdout is JSON only, never prompts):\n jam setup --agent One shot: detect, plan, apply what is safe, verify\n jam setup plan --json Report what setup would change, changing nothing\n jam setup apply --non-interactive --json\n Execute the plan\n jam doctor --json Health check as structured output\n jam auth status --json Whether Jira credentials are configured (never their value)\n jam jira search <jql> [--scope preview|complete]\n jam jira context <KEY> [KEY...]\n jam jira full <KEY> [KEY...]\n Read Jira from the shell - the same reads the MCP\n tools do, for a session that cannot see them yet\n\nEnvironment:\n JIRA_BASE_URL https://your-site.atlassian.net\n JIRA_EMAIL Atlassian account email\n JIRA_API_TOKEN Atlassian API token\n JAM_PROJECT_KEY Jira project key, used by `jam setup`/`jam serve` when no\n .jira-agent/project.yaml exists yet\n\nCredentials and JAM_PROJECT_KEY are read from the current shell's environment\nfirst, then (on Windows) from the User environment - so a value set with\n`setx` works without opening a new terminal.\n";
6
+ export declare const USAGE = "jam - Jira Agent MCP\n\nUsage:\n jam serve Run the MCP server over stdio (default; this is what Claude Code / Codex launch)\n jam doctor Diagnose config, credentials and Jira connectivity\n jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]\n Wire up this project and run doctor. Binds it to you\n alone, writing nothing to the repository; --shared\n adopts JAM for the team (project.yaml, .mcp.json)\n jam update Move this machine's registration to the published release\n jam update check What is registered, what is published (changes nothing)\n jam runtime Show which JAM build this machine runs\n jam runtime use package | development <path>\n Change it (writes ~/.jam/config.yaml only, never a project)\n jam auth login Store Jira credentials in this user's OS secret store\n jam auth logout Remove them again\n\nFor coding agents and scripts (stdout is JSON only, never prompts):\n jam setup --agent One shot: detect, plan, apply what is safe, verify\n jam setup plan --json Report what setup would change, changing nothing\n jam setup apply --non-interactive --json\n Execute the plan\n jam doctor --json Health check as structured output\n jam auth status --json Whether Jira credentials are configured (never their value)\n jam jira search <jql> [--scope preview|complete]\n jam jira context <KEY> [KEY...]\n jam jira full <KEY> [KEY...]\n Read Jira from the shell - the same reads the MCP\n tools do, for a session that cannot see them yet\n\nEnvironment:\n JIRA_BASE_URL https://your-site.atlassian.net\n JIRA_EMAIL Atlassian account email\n JIRA_API_TOKEN Atlassian API token\n JAM_PROJECT_KEY Jira project key, used by `jam setup`/`jam serve` when no\n .jira-agent/project.yaml exists yet\n\nCredentials and JAM_PROJECT_KEY are read from the current shell's environment\nfirst, then (on Windows) from the User environment - so a value set with\n`setx` works without opening a new terminal.\n";
7
7
  export declare function runJamCommand(argv: string[]): Promise<number>;
package/dist/cli-entry.js CHANGED
@@ -3,6 +3,7 @@ import { doctor } from "./cli/doctor.js";
3
3
  import { showRuntime, useRuntime } from "./cli/runtime.js";
4
4
  import { serve } from "./cli/serve.js";
5
5
  import { setup } from "./cli/setup.js";
6
+ import { jamUpdateCommand } from "./cli/update.js";
6
7
  import { runSetupWizard } from "./cli/setup-wizard.js";
7
8
  import { reportPromptError, Ui } from "./cli/ui.js";
8
9
  import { authStatusCommand, doctorJsonCommand, setupAgentCommand, setupApplyCommand, setupPlanCommand, } from "./cli/agent-api.js";
@@ -21,6 +22,8 @@ Usage:
21
22
  Wire up this project and run doctor. Binds it to you
22
23
  alone, writing nothing to the repository; --shared
23
24
  adopts JAM for the team (project.yaml, .mcp.json)
25
+ jam update Move this machine's registration to the published release
26
+ jam update check What is registered, what is published (changes nothing)
24
27
  jam runtime Show which JAM build this machine runs
25
28
  jam runtime use package | development <path>
26
29
  Change it (writes ~/.jam/config.yaml only, never a project)
@@ -99,6 +102,12 @@ export async function runJamCommand(argv) {
99
102
  // The wizard can ask; the plain path never does.
100
103
  return rest.includes("--non-interactive") ? setup(common) : runSetupWizard(common);
101
104
  }
105
+ case "update":
106
+ // Not `setup` again: setup re-plans the project binding, credentials and
107
+ // everything else. An update moves the registration pin and nothing more.
108
+ return jamUpdateCommand(rest[0] === "--json" ? undefined : rest[0], {
109
+ json: rest.includes("--json"),
110
+ });
102
111
  case "runtime": {
103
112
  const json = rest.includes("--json");
104
113
  if (rest[0] === "use")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jam-mcp/server",
3
- "version": "1.4.6",
3
+ "version": "1.5.0",
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.6",
44
+ "@jam-mcp/launcher": "1.5.0",
45
45
  "@modelcontextprotocol/sdk": "^1.30.0",
46
46
  "yaml": "^2.9.0",
47
47
  "zod": "^4.4.3"