@jam-mcp/server 1.4.3 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.3 doctor`, or just `jam
77
+ Written out, that is `npx --yes @jam-mcp/launcher@1.4.4 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.3 init` instead.
80
+ `npx --yes @jam-mcp/bootstrap@1.4.4 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
@@ -74,6 +74,15 @@ export class CompositeCredentialProvider {
74
74
  description.baseUrl = values.JIRA_BASE_URL;
75
75
  if (values.JIRA_EMAIL)
76
76
  description.email = values.JIRA_EMAIL;
77
+ // Per-field provenance, so "mixed" can be read rather than guessed at.
78
+ const sources = {};
79
+ for (const field of ["JIRA_BASE_URL", "JIRA_EMAIL", "JIRA_API_TOKEN"]) {
80
+ const from = sourceByKey[field];
81
+ if (from)
82
+ sources[field] = from;
83
+ }
84
+ if (Object.keys(sources).length > 0)
85
+ description.sources = sources;
77
86
  return description;
78
87
  }
79
88
  }
@@ -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.3", "serve"];
16
+ readonly args: readonly ["--yes", "@jam-mcp/launcher@1.4.4", "serve"];
17
17
  };
18
18
  /**
19
19
  * Recognise wiring from before the launcher existed: a hard-coded `node` path
@@ -3,13 +3,23 @@ import type { MigrationTarget } from "./migration-target.js";
3
3
  import { type BootstrapSource } from "./project-config-bootstrapper.js";
4
4
  import type { SetupState } from "./setup-state.js";
5
5
  export type SetupStatus = "already_configured" | "ready_to_apply" | "user_action_required";
6
- export type SetupCode = "JAM_PROJECT_SELECTION_REQUIRED" | "JAM_BINDINGS_UNREADABLE" | "JAM_AUTH_REQUIRED" | "JAM_RUNTIME_CONFIG_MISSING" | "JAM_PROJECT_CONFIG_INVALID" | "JAM_MCP_CONFIG_UNREADABLE" | "JAM_MIGRATION_TARGET_UNAVAILABLE";
6
+ export type SetupCode = "JAM_PROJECT_SELECTION_REQUIRED" | "JAM_BINDINGS_UNREADABLE" | "JAM_AUTH_REQUIRED" | "JAM_RUNTIME_CONFIG_MISSING" | "JAM_PROJECT_CONFIG_INVALID" | "JAM_MCP_CONFIG_UNREADABLE" | "JAM_MIGRATION_TARGET_UNAVAILABLE" | "JAM_PROJECT_KEY_CONFLICT";
7
+ /**
8
+ * Where a project key came from. `repository` is the team's committed
9
+ * `.jira-agent/project.yaml`; the rest are this user's own settings, in the
10
+ * precedence order `decideProjectKey` applies.
11
+ */
12
+ export type KeySource = BootstrapSource | "repository";
13
+ export type KeyOrigin = {
14
+ key: string;
15
+ source: KeySource;
16
+ };
7
17
  export type SetupChange = {
8
18
  type: "create";
9
19
  target: "project-config";
10
20
  path: string;
11
21
  key: string;
12
- keySource: BootstrapSource;
22
+ keySource: KeySource;
13
23
  } | {
14
24
  type: "create";
15
25
  target: "mcp-config";
@@ -30,7 +40,7 @@ export type SetupChange = {
30
40
  path: string;
31
41
  workspaceId: string;
32
42
  key: string;
33
- keySource: BootstrapSource;
43
+ keySource: KeySource;
34
44
  /** Present on a rebind, so the preview shows what is being replaced. */
35
45
  previousKey?: string;
36
46
  } | {
@@ -95,7 +105,11 @@ export type SetupPlan = {
95
105
  project?: {
96
106
  root: string;
97
107
  key?: string;
108
+ keySource?: KeySource;
98
109
  };
110
+ /** On JAM_PROJECT_KEY_CONFLICT: what was asked for, and what already stands. */
111
+ requested?: KeyOrigin;
112
+ existing?: KeyOrigin;
99
113
  };
100
114
  export type PlanOptions = {
101
115
  /**
@@ -52,7 +52,23 @@ export function computeSetupPlan(state, options = {}) {
52
52
  project: { root: state.project.root },
53
53
  };
54
54
  }
55
- const project = { root: state.project.root, key: key.key };
55
+ // The repository's committed key is the team's answer. When a personal
56
+ // `--project` disagrees with it, neither side may win silently: overwriting
57
+ // the repository is not setup's call, and quietly using the repository key
58
+ // makes the flag a lie. Stop, and name both sources.
59
+ const conflict = keyConflict(state, options, key);
60
+ if (conflict) {
61
+ return {
62
+ status: "user_action_required",
63
+ code: "JAM_PROJECT_KEY_CONFLICT",
64
+ changes: [],
65
+ requiresUserAction: true,
66
+ requested: conflict.requested,
67
+ existing: conflict.existing,
68
+ project: { root: state.project.root, key: conflict.existing.key, keySource: conflict.existing.source },
69
+ };
70
+ }
71
+ const project = { root: state.project.root, key: key.key, keySource: key.source };
56
72
  if (!shared) {
57
73
  // Personal scope: the record of "this workspace is that Jira project"
58
74
  // lives with the user, and nothing in the repository is touched.
@@ -207,9 +223,11 @@ function planBindingChange(state, key) {
207
223
  }
208
224
  function resolveKey(state, options) {
209
225
  // An existing project.yaml wins: setup must never silently repoint a project,
210
- // and a personal note must never override what the team committed.
226
+ // and a personal note must never override what the team committed. It is
227
+ // labelled `repository` rather than `explicit` - a reader has to be able to
228
+ // tell the team's committed answer from what someone typed.
211
229
  if (state.project.key)
212
- return { key: state.project.key, source: "explicit" };
230
+ return { key: state.project.key, source: "repository" };
213
231
  const decideOptions = {};
214
232
  if (options.explicitKey)
215
233
  decideOptions.explicitKey = options.explicitKey;
@@ -221,6 +239,24 @@ function resolveKey(state, options) {
221
239
  decideOptions.presetsPath = options.presetsPath;
222
240
  return decideProjectKey(state.project.root, decideOptions);
223
241
  }
242
+ /**
243
+ * A repository key and an explicit `--project` that disagree. Personal
244
+ * sources are not conflicts: explicit already beats them in decideProjectKey,
245
+ * and a stale binding is repaired rather than reported.
246
+ */
247
+ function keyConflict(state, options, resolved) {
248
+ const explicit = options.explicitKey?.trim();
249
+ if (!explicit)
250
+ return undefined;
251
+ const repository = state.project.key;
252
+ if (!repository || repository === explicit)
253
+ return undefined;
254
+ void resolved;
255
+ return {
256
+ requested: { key: explicit, source: "explicit" },
257
+ existing: { key: repository, source: "repository" },
258
+ };
259
+ }
224
260
  function planMcpChange(state, options) {
225
261
  if (!state.mcp.exists) {
226
262
  return { type: "create", target: "mcp-config", path: state.mcp.path };
@@ -1,5 +1,5 @@
1
1
  import { type RuntimeMode } from "@jam-mcp/launcher";
2
- import type { CredentialPort, CredentialSource } from "../ports/credentials.port.js";
2
+ import type { CredentialDescription, CredentialPort, CredentialSource } from "../ports/credentials.port.js";
3
3
  import { type HostRunner, type HostState } from "./host-mcp.js";
4
4
  import { type McpInspection } from "./mcp-config-merger.js";
5
5
  import { type ProjectBinding } from "./project-bindings.js";
@@ -15,6 +15,8 @@ export type RuntimeState = {
15
15
  export type CredentialState = {
16
16
  present: boolean;
17
17
  source: CredentialSource;
18
+ /** Which source supplied each field. Names only - never a value. */
19
+ sources?: CredentialDescription["sources"];
18
20
  baseUrl?: string;
19
21
  email?: string;
20
22
  };
@@ -61,6 +61,8 @@ function detectCredentials(credentials) {
61
61
  source: described.source,
62
62
  };
63
63
  // Presence and origin only - the token value never enters this snapshot.
64
+ if (described.sources)
65
+ state.sources = described.sources;
64
66
  if (described.baseUrl)
65
67
  state.baseUrl = described.baseUrl;
66
68
  if (described.email)
@@ -61,7 +61,8 @@ export async function setupApplyCommand(options = {}) {
61
61
  }
62
62
  if (plan.code === "JAM_PROJECT_CONFIG_INVALID" ||
63
63
  plan.code === "JAM_MCP_CONFIG_UNREADABLE" ||
64
- plan.code === "JAM_BINDINGS_UNREADABLE") {
64
+ plan.code === "JAM_BINDINGS_UNREADABLE" ||
65
+ plan.code === "JAM_PROJECT_KEY_CONFLICT") {
65
66
  emitJson({ ...plan, changesApplied: false });
66
67
  return 1;
67
68
  }
@@ -102,7 +103,8 @@ export async function setupAgentCommand(options = {}) {
102
103
  }
103
104
  if (plan.code === "JAM_PROJECT_CONFIG_INVALID" ||
104
105
  plan.code === "JAM_MCP_CONFIG_UNREADABLE" ||
105
- plan.code === "JAM_BINDINGS_UNREADABLE") {
106
+ plan.code === "JAM_BINDINGS_UNREADABLE" ||
107
+ plan.code === "JAM_PROJECT_KEY_CONFLICT") {
106
108
  emitJson({ ...plan, changesApplied: false });
107
109
  return 1;
108
110
  }
@@ -147,10 +149,71 @@ export async function doctorJsonCommand(options = {}) {
147
149
  ...(health.error ? { error: health.error } : {}),
148
150
  project: { root: state.project.root, ...(state.project.key ? { key: state.project.key } : {}) },
149
151
  axes,
152
+ diagnosis: diagnose(state, axes, health),
150
153
  checks: health.checks,
151
154
  });
152
155
  return passed ? 0 : 1;
153
156
  }
157
+ function diagnose(state, axes, health) {
158
+ const check = (name) => health.checks.find((c) => c.name === name);
159
+ const fromCheck = (name, code) => {
160
+ const found = check(name);
161
+ if (!found)
162
+ return { state: "UNCHECKED" };
163
+ return found.ok
164
+ ? { state: "OK", ...(found.detail ? { detail: found.detail } : {}) }
165
+ : { state: "FAILED", code, ...(found.detail ? { detail: found.detail } : {}) };
166
+ };
167
+ // Credentials: this axis answers "are all three fields resolvable, and from
168
+ // where" - the snapshot knows that. Whether Jira accepts them is a different
169
+ // question with its own axis below, and conflating the two is what made a
170
+ // working "mixed" setup read as broken. "mixed" is never a failure here.
171
+ const credentialCheck = check("Credentials present");
172
+ const mixed = state.credentials.source === "mixed";
173
+ const credentials = !state.credentials.present
174
+ ? {
175
+ state: "FAILED",
176
+ code: "JAM_AUTH_REQUIRED",
177
+ ...(credentialCheck?.detail ? { detail: credentialCheck.detail } : {}),
178
+ }
179
+ : mixed
180
+ ? { state: "WARNING", detail: `fields come from more than one source: ${describeSources(state)}` }
181
+ : { state: "OK", detail: `source ${state.credentials.source}` };
182
+ return {
183
+ credentials,
184
+ projectBinding: state.project.key
185
+ ? { state: "OK", detail: `key ${state.project.key}` }
186
+ : { state: "FAILED", code: "JAM_PROJECT_SELECTION_REQUIRED", detail: "no project key for this workspace" },
187
+ runtime: axes.package === "PACKAGE_READY"
188
+ ? { state: "OK", ...(axes.packageVersion ? { detail: axes.packageVersion } : {}) }
189
+ : { state: "FAILED", code: "JAM_RUNTIME_CONFIG_MISSING", ...(state.runtime.error ? { detail: state.runtime.error } : {}) },
190
+ registration: axes.registration === "OK"
191
+ ? { state: "OK", ...(axes.registeredVersion ? { detail: axes.registeredVersion } : {}) }
192
+ : axes.registration === "UNREGISTERED"
193
+ ? { state: "UNCHECKED", detail: "no host has a jam entry for this user" }
194
+ : { state: "FAILED", code: axes.registration, ...(axes.detail ? { detail: axes.detail } : {}) },
195
+ liveToolset: axes.live === "OK"
196
+ ? { state: "OK" }
197
+ : axes.live === "UNCHECKED"
198
+ ? { state: "UNCHECKED", ...(axes.detail ? { detail: axes.detail } : {}) }
199
+ : {
200
+ state: "FAILED",
201
+ code: axes.live,
202
+ ...(axes.missingTools ? { detail: `missing: ${axes.missingTools.join(", ")}` } : {}),
203
+ },
204
+ jiraAuthentication: fromCheck("Jira authentication", "JAM_JIRA_AUTHENTICATION_FAILED"),
205
+ jiraProjectAccess: fromCheck(health.checks.find((c) => c.name.startsWith("JQL search"))?.name ?? "JQL search", "JAM_JIRA_PROJECT_ACCESS_FAILED"),
206
+ };
207
+ }
208
+ /** Field-to-source names only. A credential value never appears here. */
209
+ function describeSources(state) {
210
+ const sources = state.credentials.sources;
211
+ if (!sources)
212
+ return "unknown";
213
+ return Object.entries(sources)
214
+ .map(([field, from]) => `${field}=${from}`)
215
+ .join(", ");
216
+ }
154
217
  async function inspectAxes(state, options) {
155
218
  const packageVersion = state.runtime.version;
156
219
  const axes = {
@@ -207,6 +270,8 @@ export function authStatusCommand(options = {}) {
207
270
  status: credentials.present ? "configured" : "not_configured",
208
271
  ...(credentials.present ? {} : { code: "JAM_AUTH_REQUIRED" }),
209
272
  source: credentials.source,
273
+ // Which field came from where. Names only - a value never appears.
274
+ ...(credentials.sources ? { sources: credentials.sources } : {}),
210
275
  ...(credentials.email ? { email: credentials.email } : {}),
211
276
  ...(credentials.baseUrl ? { baseUrl: credentials.baseUrl } : {}),
212
277
  });
package/dist/cli/auth.js CHANGED
@@ -137,7 +137,8 @@ export function authLogoutCommand(options = {}) {
137
137
  * credential unreachable, and the resulting split shows up as "mixed".
138
138
  */
139
139
  function reportOverride(ui, port) {
140
- const source = port.describe().source;
140
+ const described = port.describe();
141
+ const source = described.source;
141
142
  if (source === "secret-store")
142
143
  return;
143
144
  if (source === "mixed") {
@@ -147,6 +148,8 @@ function reportOverride(ui, port) {
147
148
  ui.warn("Current JIRA_* environment variables override the stored credentials");
148
149
  }
149
150
  ui.line(` Effective source: ${source}`);
151
+ for (const line of fieldSourceLines(described))
152
+ ui.line(line);
150
153
  ui.line(" Unset them to use what was just stored.");
151
154
  }
152
155
  /** After a logout, say plainly whether anything still authenticates JAM. */
@@ -161,6 +164,8 @@ function reportRemaining(ui, port) {
161
164
  ? "Jira credentials still resolve from outside the secret store"
162
165
  : "Part of a Jira credential still resolves from outside the secret store");
163
166
  ui.line(` Effective source: ${described.source}`);
167
+ for (const line of fieldSourceLines(described))
168
+ ui.line(line);
164
169
  ui.line(" Unset JIRA_BASE_URL, JIRA_EMAIL and JIRA_API_TOKEN to finish logging out.");
165
170
  }
166
171
  /** undefined when Jira accepted the credentials; otherwise the reason. */
@@ -200,3 +205,15 @@ export function toJiraOrigin(input) {
200
205
  }
201
206
  return url.protocol === "http:" || url.protocol === "https:" ? url.origin : undefined;
202
207
  }
208
+ /**
209
+ * Which field came from where. "mixed" on its own tells a reader that
210
+ * something is split without telling them what, so they go looking - these
211
+ * lines answer it. Field names and source names only: a credential value is
212
+ * never printed.
213
+ */
214
+ function fieldSourceLines(described) {
215
+ const sources = described.sources;
216
+ if (!sources)
217
+ return [];
218
+ return Object.entries(sources).map(([field, from]) => ` ${field}: ${from}`);
219
+ }
package/dist/cli/setup.js CHANGED
@@ -44,7 +44,8 @@ export async function setup(options = {}) {
44
44
  }
45
45
  if (plan.code === "JAM_PROJECT_CONFIG_INVALID" ||
46
46
  plan.code === "JAM_MCP_CONFIG_UNREADABLE" ||
47
- plan.code === "JAM_BINDINGS_UNREADABLE") {
47
+ plan.code === "JAM_BINDINGS_UNREADABLE" ||
48
+ plan.code === "JAM_PROJECT_KEY_CONFLICT") {
48
49
  line(`[FAIL] ${describeBlockingCode(plan)}`);
49
50
  return 1;
50
51
  }
@@ -133,6 +134,16 @@ function reportApplied(applied, plan) {
133
134
  void plan;
134
135
  }
135
136
  function describeBlockingCode(plan) {
137
+ if (plan.code === "JAM_PROJECT_KEY_CONFLICT") {
138
+ // Both sides named, and no suggestion to delete the repository's file -
139
+ // which project this repository belongs to is the team's decision.
140
+ return [
141
+ `This repository declares ${plan.existing?.key} in .jira-agent/project.yaml,`,
142
+ `but --project asked for ${plan.requested?.key}. JAM will not overwrite the`,
143
+ "committed key. Either drop --project to use the repository's, or change",
144
+ "the repository's project.yaml with the team and re-run.",
145
+ ].join(" ");
146
+ }
136
147
  if (plan.code === "JAM_PROJECT_CONFIG_INVALID") {
137
148
  return "The project's .jira-agent/project.yaml could not be parsed. Fix it and re-run.";
138
149
  }
@@ -14,6 +14,13 @@ export type CredentialDescription = {
14
14
  email?: string;
15
15
  hasToken: boolean;
16
16
  source: CredentialSource;
17
+ /**
18
+ * Which source supplied each field. `source` alone says "mixed" without
19
+ * saying mixed how, which reads as a fault when it is a normal state - a
20
+ * base URL and email in the OS store with the token exported for one shell
21
+ * is a supported setup. Names only: no value ever appears here.
22
+ */
23
+ sources?: Partial<Record<"JIRA_BASE_URL" | "JIRA_EMAIL" | "JIRA_API_TOKEN", Exclude<CredentialSource, "mixed" | "none">>>;
17
24
  };
18
25
  export interface CredentialPort {
19
26
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jam-mcp/server",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
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.3",
44
+ "@jam-mcp/launcher": "1.4.4",
45
45
  "@modelcontextprotocol/sdk": "^1.30.0",
46
46
  "yaml": "^2.9.0",
47
47
  "zod": "^4.4.3"