@jam-mcp/server 1.4.4 → 1.4.6

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.4 doctor`, or just `jam
77
+ Written out, that is `npx --yes @jam-mcp/launcher@1.4.6 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.4 init` instead.
80
+ `npx --yes @jam-mcp/bootstrap@1.4.6 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
@@ -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.4", "serve"];
16
+ readonly args: readonly ["--yes", "@jam-mcp/launcher@1.4.6", "serve"];
17
17
  };
18
18
  /**
19
19
  * Recognise wiring from before the launcher existed: a hard-coded `node` path
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Reading Jira without the MCP channel.
3
+ *
4
+ * JAM's reads have lived only behind MCP tools. That is the right home when the
5
+ * agent's session already has them - but a session that registers JAM cannot be
6
+ * shown the new tools: Claude Code has no reload surface (`claude mcp` offers
7
+ * add / get / list / login / remove / reset-project-choices / serve, and nothing
8
+ * that re-reads registrations for a running session). So the agent that just
9
+ * installed JAM has to wait for its next session before it can read anything.
10
+ *
11
+ * What it did instead was worse: it answered Jira questions from whatever was at
12
+ * hand - git log, the code host, project documents - which is the exact
13
+ * substitution JAM exists to prevent.
14
+ *
15
+ * This is the same read, addressed differently. `search` / `context` / `full`
16
+ * call the same application functions the tools call, with the same deps, the
17
+ * same policies and the same `meta`. Nothing here re-implements a read, and
18
+ * nothing here is a second source of truth.
19
+ *
20
+ * Contract, enforced by tests:
21
+ * stdout - one JSON document and nothing else, no ANSI, no prompts
22
+ * stderr - diagnostics only
23
+ */
24
+ import { type BuildDepsOptions, type JamDeps } from "../deps.js";
25
+ export declare const JIRA_READ_USAGE = "Usage:\n jam jira search <jql> [--scope preview|complete]\n jam jira context <KEY> [KEY...]\n jam jira full <KEY> [KEY...]\n\nReads only. Output is one JSON document on stdout - the same result the MCP\ntools return, for a session that cannot see them yet.\n";
26
+ export type JiraReadOptions = BuildDepsOptions & {
27
+ /** Injected by tests so no test reaches a real Jira. */
28
+ deps?: JamDeps;
29
+ /** Where the JSON document goes. Defaults to stdout. */
30
+ write?: (text: string) => void;
31
+ /** Where diagnostics go. Defaults to stderr. */
32
+ warn?: (text: string) => void;
33
+ };
34
+ export declare function runJiraRead(argv: readonly string[], options?: JiraReadOptions): Promise<number>;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Reading Jira without the MCP channel.
3
+ *
4
+ * JAM's reads have lived only behind MCP tools. That is the right home when the
5
+ * agent's session already has them - but a session that registers JAM cannot be
6
+ * shown the new tools: Claude Code has no reload surface (`claude mcp` offers
7
+ * add / get / list / login / remove / reset-project-choices / serve, and nothing
8
+ * that re-reads registrations for a running session). So the agent that just
9
+ * installed JAM has to wait for its next session before it can read anything.
10
+ *
11
+ * What it did instead was worse: it answered Jira questions from whatever was at
12
+ * hand - git log, the code host, project documents - which is the exact
13
+ * substitution JAM exists to prevent.
14
+ *
15
+ * This is the same read, addressed differently. `search` / `context` / `full`
16
+ * call the same application functions the tools call, with the same deps, the
17
+ * same policies and the same `meta`. Nothing here re-implements a read, and
18
+ * nothing here is a second source of truth.
19
+ *
20
+ * Contract, enforced by tests:
21
+ * stdout - one JSON document and nothing else, no ANSI, no prompts
22
+ * stderr - diagnostics only
23
+ */
24
+ import { getFullIssueContext } from "../application/get-full-issue-context.js";
25
+ import { getIssueContext } from "../application/get-issue-context.js";
26
+ import { searchIssues } from "../application/search-issues.js";
27
+ import { buildDeps } from "../deps.js";
28
+ import { toJamError } from "../domain/errors.js";
29
+ export const JIRA_READ_USAGE = `Usage:
30
+ jam jira search <jql> [--scope preview|complete]
31
+ jam jira context <KEY> [KEY...]
32
+ jam jira full <KEY> [KEY...]
33
+
34
+ Reads only. Output is one JSON document on stdout - the same result the MCP
35
+ tools return, for a session that cannot see them yet.
36
+ `;
37
+ /** `--scope complete` / `--scope=complete`, and nothing invented when absent. */
38
+ function flagValue(argv, flag) {
39
+ const index = argv.indexOf(flag);
40
+ if (index >= 0)
41
+ return argv[index + 1];
42
+ const inline = argv.find((arg) => arg.startsWith(`${flag}=`));
43
+ return inline ? inline.slice(flag.length + 1) : undefined;
44
+ }
45
+ const positional = (argv) => {
46
+ const out = [];
47
+ for (let i = 0; i < argv.length; i += 1) {
48
+ const arg = argv[i];
49
+ if (arg === "--scope") {
50
+ i += 1;
51
+ continue;
52
+ }
53
+ if (arg.startsWith("--"))
54
+ continue;
55
+ out.push(arg);
56
+ }
57
+ return out;
58
+ };
59
+ export async function runJiraRead(argv, options = {}) {
60
+ const write = options.write ?? ((text) => process.stdout.write(text));
61
+ const warn = options.warn ?? ((text) => process.stderr.write(text));
62
+ const [subcommand, ...rest] = argv;
63
+ if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
64
+ warn(JIRA_READ_USAGE);
65
+ return subcommand ? 0 : 1;
66
+ }
67
+ if (subcommand !== "search" && subcommand !== "context" && subcommand !== "full") {
68
+ warn(`Unknown jira command: ${subcommand}\n\n${JIRA_READ_USAGE}`);
69
+ return 1;
70
+ }
71
+ const args = positional(rest);
72
+ if (args.length === 0) {
73
+ warn(subcommand === "search" ? "jam jira search needs a JQL query.\n" : `jam jira ${subcommand} needs at least one issue key.\n`);
74
+ return 1;
75
+ }
76
+ const scope = flagValue(rest, "--scope");
77
+ if (scope !== undefined && scope !== "preview" && scope !== "complete") {
78
+ warn(`--scope is preview|complete (got ${scope}).\n`);
79
+ return 1;
80
+ }
81
+ try {
82
+ const { deps: injected, write: _w, warn: _n, ...depsOptions } = options;
83
+ const deps = injected ?? (await buildDeps(depsOptions));
84
+ const result = subcommand === "search"
85
+ ? await searchIssues(deps, { jql: args.join(" "), ...(scope ? { scope } : {}) })
86
+ : subcommand === "context"
87
+ ? await getIssueContext(deps, { issueKeys: args })
88
+ : await getFullIssueContext(deps, { issueKeys: args });
89
+ write(`${JSON.stringify(result)}\n`);
90
+ return 0;
91
+ }
92
+ catch (err) {
93
+ // The same normalized codes the tools produce - an agent reads one contract,
94
+ // not two.
95
+ write(`${JSON.stringify(toJamError(err).toPayload())}\n`);
96
+ return 1;
97
+ }
98
+ }
@@ -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\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 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
@@ -6,6 +6,7 @@ import { setup } from "./cli/setup.js";
6
6
  import { runSetupWizard } from "./cli/setup-wizard.js";
7
7
  import { reportPromptError, Ui } from "./cli/ui.js";
8
8
  import { authStatusCommand, doctorJsonCommand, setupAgentCommand, setupApplyCommand, setupPlanCommand, } from "./cli/agent-api.js";
9
+ import { runJiraRead } from "./cli/jira-read.js";
9
10
  /**
10
11
  * Command dispatch for the JAM CLI, separated from the bin so other entry
11
12
  * points (notably @jam-mcp/bootstrap) can forward to exactly these commands
@@ -33,6 +34,11 @@ For coding agents and scripts (stdout is JSON only, never prompts):
33
34
  Execute the plan
34
35
  jam doctor --json Health check as structured output
35
36
  jam auth status --json Whether Jira credentials are configured (never their value)
37
+ jam jira search <jql> [--scope preview|complete]
38
+ jam jira context <KEY> [KEY...]
39
+ jam jira full <KEY> [KEY...]
40
+ Read Jira from the shell - the same reads the MCP
41
+ tools do, for a session that cannot see them yet
36
42
 
37
43
  Environment:
38
44
  JIRA_BASE_URL https://your-site.atlassian.net
@@ -112,6 +118,10 @@ export async function runJamCommand(argv) {
112
118
  process.stderr.write("Usage: jam auth login | status [--json] | logout\n");
113
119
  return 1;
114
120
  }
121
+ case "jira":
122
+ // Reads addressed to the shell, for a session that cannot see the MCP
123
+ // tools yet. Same application path as the tools - see cli/jira-read.ts.
124
+ return runJiraRead(rest);
115
125
  case "help":
116
126
  case "--help":
117
127
  case "-h":
@@ -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] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jam-mcp/server",
3
- "version": "1.4.4",
3
+ "version": "1.4.6",
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.4",
44
+ "@jam-mcp/launcher": "1.4.6",
45
45
  "@modelcontextprotocol/sdk": "^1.30.0",
46
46
  "yaml": "^2.9.0",
47
47
  "zod": "^4.4.3"