@jam-mcp/server 1.4.3 → 1.4.5

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.5 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.5 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
  }
@@ -22,6 +22,15 @@ export function mapIssueWithMeta(raw, config) {
22
22
  customFields: mapCustomFields(f, config),
23
23
  comments: [],
24
24
  };
25
+ // Jira returns `id` as a property of the issue resource, not as a field, so
26
+ // it arrives whatever the field list says and costs nothing to keep. Set
27
+ // only when Jira sent one: an empty string would read as an identity that
28
+ // was looked at and found blank.
29
+ if (raw.id)
30
+ issue.issueId = raw.id;
31
+ const statusCategory = category(f["status"]);
32
+ if (statusCategory)
33
+ issue.statusCategory = statusCategory;
25
34
  const assignee = user(f["assignee"]);
26
35
  if (assignee)
27
36
  issue.assignee = assignee;
@@ -123,12 +132,17 @@ export function normalizeFieldValue(value) {
123
132
  }
124
133
  function issueRef(raw) {
125
134
  const ref = { key: raw.key ?? "" };
135
+ if (raw.id)
136
+ ref.issueId = raw.id;
126
137
  const summary = raw.fields?.summary;
127
138
  if (summary)
128
139
  ref.summary = summary;
129
140
  const status = raw.fields?.status?.name;
130
141
  if (status)
131
142
  ref.status = status;
143
+ const statusCategory = raw.fields?.status?.statusCategory?.key;
144
+ if (statusCategory)
145
+ ref.statusCategory = statusCategory;
132
146
  return ref;
133
147
  }
134
148
  function str(v) {
@@ -137,6 +151,17 @@ function str(v) {
137
151
  function named(v) {
138
152
  return v?.name;
139
153
  }
154
+ /**
155
+ * Jira's status category key, or nothing.
156
+ *
157
+ * Nothing, specifically, rather than a guess: the alternative is matching
158
+ * `status.name` against a list of words that mean "done", which is wrong in
159
+ * every language a project is not configured in and wrong in English the
160
+ * moment someone renames a status.
161
+ */
162
+ function category(v) {
163
+ return v?.statusCategory?.key;
164
+ }
140
165
  function user(v) {
141
166
  const u = v;
142
167
  return u?.displayName ?? u?.emailAddress ?? undefined;
@@ -26,11 +26,12 @@ export async function applyCreateIssue(deps, plan) {
26
26
  }
27
27
  await revalidateSchema(deps, plan);
28
28
  const created = await create(deps, plan);
29
- const after = await verify(deps, plan, created.key);
29
+ const { observed: after, issueId } = await verify(deps, plan, created);
30
30
  deps.writePlans.consume(plan.planId);
31
31
  return {
32
32
  status: "applied",
33
33
  issue: created.key,
34
+ issueId,
34
35
  operation: plan.operation,
35
36
  before: plan.before,
36
37
  after,
@@ -118,7 +119,8 @@ function isAmbiguous(err) {
118
119
  * a project's automation adds - and none of that was requested, so requiring
119
120
  * it to match something would be inventing an expectation nobody stated.
120
121
  */
121
- async function verify(deps, plan, issueKey) {
122
+ async function verify(deps, plan, created) {
123
+ const issueKey = created.key;
122
124
  // Where the issue landed is part of what was intended. The workspace binding
123
125
  // is the whole of JAM's write scope, so a key from another project coming
124
126
  // back from a create is the one outcome that must never be reported as the
@@ -127,7 +129,15 @@ async function verify(deps, plan, issueKey) {
127
129
  if (createdProject !== plan.projectKey) {
128
130
  throw verificationFailed(plan, issueKey, { project: plan.projectKey }, { project: createdProject ?? issueKey });
129
131
  }
130
- const { issue } = await readIssue(deps, issueKey);
132
+ const { issue, issueId } = await readIssue(deps, issueKey);
133
+ // Jira named both the id and the key when it accepted the create; the
134
+ // read-back names them again. They have to agree - a key that already
135
+ // resolves to a different issue than the one just created is the one case
136
+ // where reporting the created key would point whoever reads the receipt at
137
+ // somebody else's issue.
138
+ if (issueId !== created.id) {
139
+ throw verificationFailed(plan, issueKey, { issueId: created.id }, { issueId });
140
+ }
131
141
  const observed = {};
132
142
  for (const field of Object.keys(plan.intendedAfter)) {
133
143
  observed[field] = observedValue(issue, field);
@@ -137,7 +147,7 @@ async function verify(deps, plan, issueKey) {
137
147
  throw verificationFailed(plan, issueKey, plan.intendedAfter, observed);
138
148
  }
139
149
  }
140
- return observed;
150
+ return { observed, issueId };
141
151
  }
142
152
  /**
143
153
  * The created issue's value for one requested field.
@@ -1,7 +1,7 @@
1
1
  import { JamError, toJamError } from "../domain/errors.js";
2
2
  import { readModeAfterWrite } from "../policy/consistency-policy.js";
3
3
  import { assertAssignable } from "../policy/assignee-policy.js";
4
- import { assertUnchanged } from "../policy/write-policy.js";
4
+ import { assertSameIssue, assertUnchanged } from "../policy/write-policy.js";
5
5
  import { applyCreateIssue } from "./apply-create-issue.js";
6
6
  import { readIssue } from "./plan-write.js";
7
7
  /**
@@ -36,6 +36,10 @@ export async function applyWritePlan(deps, request) {
36
36
  if (plan.kind === "create-issue")
37
37
  return applyCreateIssue(deps, plan);
38
38
  const current = await readIssue(deps, plan.issueKey);
39
+ // Identity before revision: if the key now names a different issue, its
40
+ // `updated` timestamp is a fact about something nobody planned to change,
41
+ // and comparing it would be answering the wrong question.
42
+ assertSameIssue(plan.issueKey, plan.issueId, current.issueId);
39
43
  assertUnchanged(plan.issueKey, plan.baseUpdated, current.issue.updated);
40
44
  // Whatever the plan depends on that the revision check cannot see, checked
41
45
  // again here. For an assignment that is the target's permission to hold this
@@ -48,6 +52,7 @@ export async function applyWritePlan(deps, request) {
48
52
  return {
49
53
  status: "applied",
50
54
  issue: plan.issueKey,
55
+ issueId: plan.issueId,
51
56
  operation: plan.operation,
52
57
  before: plan.before,
53
58
  after,
@@ -126,6 +131,10 @@ function isAmbiguous(err) {
126
131
  async function verify(deps, plan) {
127
132
  const snapshot = await readIssue(deps, plan.issueKey);
128
133
  const issue = snapshot.issue;
134
+ // The same identity question again, for the read that produces the evidence.
135
+ // Confirming the intended value on an issue the key has since come to name
136
+ // would be reporting somebody else's state as proof of our write.
137
+ assertSameIssue(plan.issueKey, plan.issueId, snapshot.issueId);
129
138
  if (plan.mutation.kind === "assignee") {
130
139
  // On the accountId, never on the display name. Two people can share a
131
140
  // name, so a name comparison would accept the wrong person's assignment as
@@ -38,6 +38,16 @@ export declare function planWrite(deps: JamDeps, request: PlanWriteRequest): Pro
38
38
  */
39
39
  export type IssueSnapshot = {
40
40
  issue: FullIssueContext;
41
+ /**
42
+ * The issue's canonical Jira id, carried separately because the write plane
43
+ * requires it and the read shape only offers it.
44
+ *
45
+ * A key is a locator: Jira can move one between issues, and an integration
46
+ * that recorded a key months ago is holding a string, not a target. Every
47
+ * write is pinned to this instead - planned against it, re-checked against
48
+ * it before the mutation, and confirmed against it afterwards.
49
+ */
50
+ issueId: string;
41
51
  /** Identity of the current assignee, which `issue.assignee` cannot supply. */
42
52
  assigneeAccountId?: string;
43
53
  };
@@ -38,6 +38,7 @@ export async function planWrite(deps, request) {
38
38
  const plan = deps.writePlans.create({
39
39
  kind: "existing-issue",
40
40
  issueKey,
41
+ issueId: snapshot.issueId,
41
42
  projectKey,
42
43
  operation,
43
44
  before,
@@ -55,6 +56,7 @@ export async function planWrite(deps, request) {
55
56
  status: "planned",
56
57
  planId: plan.planId,
57
58
  issue: plan.issueKey,
59
+ issueId: plan.issueId,
58
60
  operation: plan.operation,
59
61
  before: plan.before,
60
62
  intendedAfter: plan.intendedAfter,
@@ -86,7 +88,20 @@ export async function readIssue(deps, issueKey) {
86
88
  if (!found) {
87
89
  throw new JamError("ISSUE_NOT_FOUND", `Jira has no issue ${issueKey}, or it is not visible to this account.`, { issueKey });
88
90
  }
89
- return { issue: found, ...(assigneeAccountId ? { assigneeAccountId } : {}) };
91
+ // Jira answered with an issue but named no canonical id. That is not a
92
+ // resolution JAM can build a write on, and it is refused here - at the one
93
+ // read every write goes through - rather than by filling the field with an
94
+ // empty string and carrying a fake identity into a plan. Reads are
95
+ // unaffected: a list of issues is not invalidated because one entry arrived
96
+ // thin, and only the write plane treats identity as proof.
97
+ if (!found.issueId) {
98
+ throw new JamError("PARTIAL_RESULT", `Jira returned ${issueKey} without a canonical issue id, so JAM cannot confirm which issue this key currently names. Read the issue in Jira before changing it.`, { issueKey });
99
+ }
100
+ return {
101
+ issue: found,
102
+ issueId: found.issueId,
103
+ ...(assigneeAccountId ? { assigneeAccountId } : {}),
104
+ };
90
105
  }
91
106
  /**
92
107
  * The issue an existing-issue operation names, or a refusal that says why.
@@ -77,10 +77,19 @@ export async function searchIssues(deps, input) {
77
77
  }
78
78
  /** Project down to lite fields so heavy data cannot leak through this path. */
79
79
  export function toSummary(issue) {
80
+ // Identity and status semantics ride along at every level, next to the
81
+ // fields they qualify. Both are already in the payload this projection is
82
+ // narrowing, so carrying them costs nothing - and dropping them would make
83
+ // the cheapest read the one an agent cannot safely act on: a key with no
84
+ // identity behind it, and a status name whose meaning it would have to
85
+ // guess. Spread rather than assigned afterwards so the JSON an agent reads
86
+ // puts each beside its subject.
80
87
  const summary = {
81
88
  key: issue.key,
89
+ ...(issue.issueId ? { issueId: issue.issueId } : {}),
82
90
  summary: issue.summary,
83
91
  status: issue.status,
92
+ ...(issue.statusCategory ? { statusCategory: issue.statusCategory } : {}),
84
93
  updated: issue.updated,
85
94
  labels: issue.labels,
86
95
  components: issue.components,
@@ -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.5", "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
  }
@@ -1,7 +1,24 @@
1
+ /**
2
+ * A reference to a Jira issue.
3
+ *
4
+ * `key` is what a person types, a branch name carries and an integration links
5
+ * on - and it is not an identity. Jira mints keys per project and a key can be
6
+ * moved between issues, so the same string can name a different issue later.
7
+ * `issueId` is the identity: the immutable id Jira assigns once and never
8
+ * reuses.
9
+ *
10
+ * `issueId` is optional because JAM will not invent one. Jira supplies it on
11
+ * every issue resource and on the nested references it embeds, but a payload
12
+ * that omits it leaves the field absent rather than empty - "not returned" and
13
+ * "returned blank" are different facts, and only one of them is true.
14
+ */
1
15
  export type IssueRef = {
2
16
  key: string;
17
+ issueId?: string;
3
18
  summary?: string;
4
19
  status?: string;
20
+ /** See IssueSummary.statusCategory. */
21
+ statusCategory?: string;
5
22
  };
6
23
  /**
7
24
  * SEARCH level. Deliberately excludes description/comments/attachments/changelog
@@ -9,8 +26,25 @@ export type IssueRef = {
9
26
  */
10
27
  export type IssueSummary = {
11
28
  key: string;
29
+ /** Jira's immutable issue id. See IssueRef. */
30
+ issueId?: string;
12
31
  summary: string;
13
32
  status: string;
33
+ /**
34
+ * Jira's own machine-readable status category key, as Jira publishes it -
35
+ * currently `new`, `indeterminate` or `done`, plus `undefined` for a status
36
+ * in no category.
37
+ *
38
+ * Passed through, never derived. `status` is a workflow-defined, localized
39
+ * name: a project can call a category-`done` status "Shipped", "완료" or
40
+ * "Won't Fix", and matching those strings is how an agent decides an issue
41
+ * is finished when it is not. This is the field to read instead, and JAM
42
+ * neither renames Jira's values nor turns them into a verdict of its own.
43
+ *
44
+ * Absent when Jira returned a status without a category. Absent is absent -
45
+ * it is not `new`.
46
+ */
47
+ statusCategory?: string;
14
48
  assignee?: string;
15
49
  priority?: string;
16
50
  updated: string;
@@ -172,6 +172,16 @@ type WritePlanCommon = {
172
172
  export type ExistingIssueWritePlan = WritePlanCommon & {
173
173
  kind: "existing-issue";
174
174
  issueKey: string;
175
+ /**
176
+ * The canonical Jira id of the issue this plan was made against.
177
+ *
178
+ * `issueKey` says where to look; this says what was found there. They are
179
+ * not the same guarantee: a key is a locator Jira can move between issues,
180
+ * so re-reading the key at apply time can return a different issue than the
181
+ * one the plan described. Comparing this is what turns "the key still
182
+ * resolves" into "it still resolves to the issue that was planned".
183
+ */
184
+ issueId: string;
175
185
  operation: ExistingIssueOperation;
176
186
  baseUpdated: string;
177
187
  /**
@@ -239,6 +249,11 @@ export type WritePlanReceipt = {
239
249
  * yet - a placeholder key here would be a claim JAM cannot make.
240
250
  */
241
251
  issue?: string;
252
+ /**
253
+ * The canonical Jira id of that issue. Absent for `issue.create` for the
254
+ * same reason `issue` is: Jira mints both, and it has not been asked yet.
255
+ */
256
+ issueId?: string;
242
257
  /** The project a new issue would be created in. Present for `issue.create`. */
243
258
  project?: string;
244
259
  /** How the result of applying this plan will be confirmed. */
@@ -253,6 +268,14 @@ export type WriteApplyReceipt = {
253
268
  status: "applied";
254
269
  /** For `issue.create`, the key Jira minted - known only after applying. */
255
270
  issue: string;
271
+ /**
272
+ * The canonical Jira id of the issue that was written, read back from Jira.
273
+ *
274
+ * What to record if this write is going to be referred to later. The key is
275
+ * how a person and an integration will find the issue; this is what says the
276
+ * thing they find is the thing that was changed.
277
+ */
278
+ issueId: string;
256
279
  operation: WriteOperation;
257
280
  before: Record<string, unknown>;
258
281
  after: Record<string, unknown>;
@@ -9,7 +9,11 @@ Returns everything jira_search returns plus issue type, parent, subtasks, issue
9
9
 
10
10
  This is the Jira-recorded evidence relevant to readiness, blockers, dependencies and priority - not a readiness verdict. blocksThisIssue reports how Jira words a link; an empty links array means Jira holds no visible link for you, not that nothing blocks the work. Repository and external sources are not evaluated.
11
11
 
12
- Pass every key you care about in one call; they are fetched in a single batched round trip. Check meta.complete and meta.missingKeys before drawing conclusions.`;
12
+ Every issue carries issueId, Jira's immutable id, alongside key. The key is the current human- and integration-facing locator and Jira can move it to another issue; issueId is the identity. Record issueId when a reference has to survive. statusCategory is Jira's own machine-readable category for the status - read it instead of matching status text, which is workflow-defined and localized. Parent, subtasks and links carry issueId too, wherever Jira supplies one.
13
+
14
+ Pass every key you care about in one call; they are fetched in a single batched round trip. Check meta.complete and meta.missingKeys before drawing conclusions.
15
+
16
+ This is also how a Jira issue key is checked. Asking for an exact key and getting an issue back is a positive resolution: that key names that issue, right now. A key listed in meta.missingKeys resolved to nothing JAM can see - it may not exist, or it may not be visible to this account, and those are indistinguishable from here. Either way it is unusable, and it is NOT evidence that the number is free, unused or reservable. Never synthesize, increment, predict or reserve a Jira issue key; keys are minted by Jira.`;
13
17
  export function registerJiraContext(server, deps) {
14
18
  server.registerTool("jira_context", {
15
19
  title: "Jira issue context (dependencies, blockers, readiness)",
@@ -5,7 +5,7 @@ const DESCRIPTION = `Get the complete record for one or more Jira issues, includ
5
5
 
6
6
  Use for final judgements: was this agreed, is the contract settled, was it approved, can it be closed, what did the other team actually answer, what does this issue mean right now.
7
7
 
8
- Returns everything jira_context returns plus description and every comment (normalized to plain text). This is the most expensive tool - prefer jira_search for listing and jira_context for readiness, and reach for this one when the answer must not be wrong.
8
+ Returns everything jira_context returns - issueId and statusCategory included - plus description and every comment (normalized to plain text). This is the most expensive tool - prefer jira_search for listing and jira_context for readiness, and reach for this one when the answer must not be wrong.
9
9
 
10
10
  Ask for as few keys as possible: with several issues at once the output budget may drop the oldest comments. Always check meta.commentsComplete and meta.complete - if either is false, the thread you are reading is partial and a "yes, it is agreed" answer is not supported.
11
11
 
@@ -5,10 +5,12 @@ const DESCRIPTION = `Find Jira issues by JQL and get a lightweight list back.
5
5
 
6
6
  Use for: discovery, listing, "what is open", "what is assigned to me", recent changes, picking candidate issues.
7
7
 
8
- Returns key, summary, status, assignee, priority, updated, labels and components only. It deliberately does NOT return description, comments, attachments or links - that keeps listing cheap.
8
+ Returns key, issueId, summary, status, statusCategory, assignee, priority, updated, labels and components only. It deliberately does NOT return description, comments, attachments or links - that keeps listing cheap.
9
9
 
10
10
  Because of that, a jira_search result is NOT complete issue context. Never conclude from it that something is agreed, approved, unblocked, or done. Follow up with jira_context (readiness, blockers, dependencies, priority) or jira_full (agreement, contract, approval, closure).
11
11
 
12
+ Every issue carries issueId, Jira's immutable id, alongside key. The key is the current human- and integration-facing locator and Jira can move it to another issue; issueId is the identity. Record issueId when a reference has to survive. statusCategory is Jira's own machine-readable category for the status - read it instead of matching status text, which is workflow-defined and localized.
13
+
12
14
  Repository and external sources are not evaluated.
13
15
 
14
16
  scope="preview" (default) returns the first page for interactive exploration. scope="complete" walks every page - use it whenever the answer depends on the total count or on seeing every match. Check meta.complete before treating the list as exhaustive.`;
@@ -31,6 +31,21 @@ export declare function projectKeyOf(issueKey: string): string | undefined;
31
31
  * comment - must not be able to reach into another team's project through it.
32
32
  */
33
33
  export declare function assertWriteScope(issueKey: string, configuredProject: string): string;
34
+ /**
35
+ * The key still names the issue the plan was made against.
36
+ *
37
+ * A revision check answers "has this issue changed"; this answers the question
38
+ * underneath it - "is this the same issue at all". Jira keys are locators, not
39
+ * identities: one can be moved to another issue, and an integration holding
40
+ * the string would then be pointed somewhere nobody chose. Re-reading the key
41
+ * and finding a different canonical id means the plan describes an issue this
42
+ * key no longer names, and the answer is a new plan, not this write.
43
+ *
44
+ * JAM_WRITE_CONFLICT rather than a code of its own: the situation is the one
45
+ * an agent already knows how to handle - the ground moved, plan again against
46
+ * the current state - and a second code for it would only fragment that.
47
+ */
48
+ export declare function assertSameIssue(issueKey: string, planned: string, observed: string): void;
34
49
  export declare function assertOperationAllowed(operation: string): WriteOperation;
35
50
  /**
36
51
  * Narrow an already-allowed operation to one that acts on an existing issue.
@@ -48,6 +48,25 @@ export function assertWriteScope(issueKey, configuredProject) {
48
48
  }
49
49
  return project;
50
50
  }
51
+ /**
52
+ * The key still names the issue the plan was made against.
53
+ *
54
+ * A revision check answers "has this issue changed"; this answers the question
55
+ * underneath it - "is this the same issue at all". Jira keys are locators, not
56
+ * identities: one can be moved to another issue, and an integration holding
57
+ * the string would then be pointed somewhere nobody chose. Re-reading the key
58
+ * and finding a different canonical id means the plan describes an issue this
59
+ * key no longer names, and the answer is a new plan, not this write.
60
+ *
61
+ * JAM_WRITE_CONFLICT rather than a code of its own: the situation is the one
62
+ * an agent already knows how to handle - the ground moved, plan again against
63
+ * the current state - and a second code for it would only fragment that.
64
+ */
65
+ export function assertSameIssue(issueKey, planned, observed) {
66
+ if (planned === observed)
67
+ return;
68
+ throw new JamError("JAM_WRITE_CONFLICT", `${issueKey} no longer names the issue this plan was made against (planned ${planned}, now ${observed}). Plan again against the issue the key names now.`, { issueKey, plannedIssueId: planned, observedIssueId: observed });
69
+ }
51
70
  export function assertOperationAllowed(operation) {
52
71
  if (!isWriteOperation(operation)) {
53
72
  throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", `"${operation}" is not a JAM write operation. Supported: ${WRITE_OPERATIONS.join(", ")}.`, { operation, supported: [...WRITE_OPERATIONS] });
@@ -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.5",
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.5",
45
45
  "@modelcontextprotocol/sdk": "^1.30.0",
46
46
  "yaml": "^2.9.0",
47
47
  "zod": "^4.4.3"