@jam-mcp/server 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,10 +74,10 @@ auth login Store Jira credentials in this user's OS secret store
74
74
  runtime Show or change which JAM build this machine runs
75
75
  ```
76
76
 
77
- Written out, that is `npx --yes @jam-mcp/launcher@1.6.0 doctor`, or just `jam
77
+ Written out, that is `npx --yes @jam-mcp/launcher@1.7.0 doctor`, or just `jam
78
78
  doctor` if you took the launcher's optional global install. Starting from
79
79
  nothing — no install, no runtime chosen yet — use
80
- `npx --yes @jam-mcp/bootstrap@1.6.0 init` instead.
80
+ `npx --yes @jam-mcp/bootstrap@1.7.0 init` instead.
81
81
 
82
82
  Credentials come from the process environment or this user's OS secret store —
83
83
  never from a repository file — and never appear in logs, telemetry, or tool
@@ -1,7 +1,8 @@
1
1
  import { JamError, toJamError } from "../domain/errors.js";
2
2
  import { canonicalizePlainText } from "../domain/adf.js";
3
- import { assertSchemaUnchanged } from "../policy/create-policy.js";
3
+ import { assertSchemaUnchanged, CREATE_FIELD_IDS } from "../policy/create-policy.js";
4
4
  import { projectKeyOf } from "../policy/write-policy.js";
5
+ import { toJiraCreateFields, validateCreateInput } from "./plan-create-issue.js";
5
6
  import { readIssue } from "./plan-write.js";
6
7
  /**
7
8
  * Create the issue a plan describes, then go and look at what was created.
@@ -24,6 +25,11 @@ export async function applyCreateIssue(deps, plan) {
24
25
  if (plan.mutation.kind !== "create") {
25
26
  throw new JamError("CONFIG_INVALID", "A create-issue plan must carry a create mutation.");
26
27
  }
28
+ // Plans come off disk now, so the fields being sent are checked against what
29
+ // this plan's own input produces before anything is created. Editing the
30
+ // stored mutation without editing the input is caught here; editing both is
31
+ // asking JAM to plan that create, which is the supported way to get one.
32
+ assertCreateMutationMatchesInput(plan);
27
33
  await revalidateSchema(deps, plan);
28
34
  const created = await create(deps, plan);
29
35
  const { observed: after, issueId } = await verify(deps, plan, created);
@@ -45,6 +51,47 @@ export async function applyCreateIssue(deps, plan) {
45
51
  * not "is the schema identical" - on an active project it rarely is - but "are
46
52
  * this plan's premises still true". See assertSchemaUnchanged.
47
53
  */
54
+ /**
55
+ * Check the stored create fields are the ones this plan's input produces.
56
+ *
57
+ * The existing-issue path does the same thing in apply-write.ts, and for the
58
+ * same reason: a plan is a file, and re-deriving is what makes editing it
59
+ * pointless. Creation can do it without touching Jira - `toJiraCreateFields`
60
+ * is pure once the issue type and resolved values are known, and those are in
61
+ * `schemaRequirements`, which `revalidateSchema` checks against Jira right
62
+ * after this.
63
+ *
64
+ * Nothing has been created when this refuses.
65
+ */
66
+ function assertCreateMutationMatchesInput(plan) {
67
+ const { schemaRequirements: schema } = plan;
68
+ // One entry per requested value, so a component list resolves item by item -
69
+ // matching on fieldId alone would give every component the first one's answer.
70
+ const resolvedFor = (fieldId, requested) => schema.resolvedValues.find((entry) => entry.fieldId === fieldId && entry.requested === requested)
71
+ ?.resolved;
72
+ let derived;
73
+ try {
74
+ const input = validateCreateInput(plan.input);
75
+ const priority = input.priority !== undefined
76
+ ? resolvedFor(CREATE_FIELD_IDS.priority, input.priority)
77
+ : undefined;
78
+ derived = toJiraCreateFields(plan.projectKey, schema.issueTypeId, input, {
79
+ ...(priority !== undefined ? { priority } : {}),
80
+ ...(input.components !== undefined
81
+ ? {
82
+ components: input.components.map((name) => resolvedFor(CREATE_FIELD_IDS.components, name) ?? name),
83
+ }
84
+ : {}),
85
+ });
86
+ }
87
+ catch (err) {
88
+ throw new JamError("JAM_WRITE_PLAN_TAMPERED", `This create plan does not describe an issue JAM would create in ${plan.projectKey}. Nothing was created - call jira_write_plan again.`, { planId: plan.planId, project: plan.projectKey, reason: toJamError(err).code });
89
+ }
90
+ const recorded = plan.mutation.kind === "create" ? plan.mutation.fields : undefined;
91
+ if (JSON.stringify(derived) !== JSON.stringify(recorded)) {
92
+ throw new JamError("JAM_WRITE_PLAN_TAMPERED", `This create plan's recorded fields do not match what its input produces. Nothing was created - call jira_write_plan again.`, { planId: plan.planId, project: plan.projectKey });
93
+ }
94
+ }
48
95
  async function revalidateSchema(deps, plan) {
49
96
  const { projectKey, schemaRequirements } = plan;
50
97
  const issueTypes = await deps.jiraCreateMetadata.getIssueTypes(projectKey);
@@ -3,7 +3,7 @@ import { readModeAfterWrite } from "../policy/consistency-policy.js";
3
3
  import { assertAssignable } from "../policy/assignee-policy.js";
4
4
  import { assertSameIssue, assertUnchanged } from "../policy/write-policy.js";
5
5
  import { applyCreateIssue } from "./apply-create-issue.js";
6
- import { readIssue } from "./plan-write.js";
6
+ import { readIssue, toJiraFields, validateInput } from "./plan-write.js";
7
7
  /**
8
8
  * Execute a plan JAM made, then go and look at what happened.
9
9
  *
@@ -41,6 +41,11 @@ export async function applyWritePlan(deps, request) {
41
41
  // and comparing it would be answering the wrong question.
42
42
  assertSameIssue(plan.issueKey, plan.issueId, current.issueId);
43
43
  assertUnchanged(plan.issueKey, plan.baseUpdated, current.issue.updated);
44
+ // The plan came off disk, so derive its mutation again and check it is the
45
+ // one stored. This is after the revision check on purpose: a moved issue is
46
+ // a conflict, not tampering, and saying so in that order keeps the two
47
+ // situations from being reported as each other.
48
+ assertMutationMatchesInput(plan);
44
49
  // Whatever the plan depends on that the revision check cannot see, checked
45
50
  // again here. For an assignment that is the target's permission to hold this
46
51
  // issue: it can be revoked between planning and applying, and a plan that
@@ -60,6 +65,82 @@ export async function applyWritePlan(deps, request) {
60
65
  ...(outcome.commentId ? { commentId: outcome.commentId } : {}),
61
66
  };
62
67
  }
68
+ /**
69
+ * Check the stored mutation is the one this plan's input produces.
70
+ *
71
+ * Plans used to live in the process that made them, so what apply sent could
72
+ * only have come from planning. They live in a file now - shared so that a
73
+ * session whose MCP channel died can still apply one from the shell - and a
74
+ * file can be edited.
75
+ *
76
+ * What replaces the in-process guarantee is this: run the same derivation
77
+ * planning ran, on the plan's own input, and refuse if the answer differs.
78
+ * Editing `mutation` alone is caught here. Editing `input` to match means
79
+ * asking JAM to derive that mutation, which is what `jira_write_plan` is - so
80
+ * there is nothing to gain by forging a plan that could not be obtained by
81
+ * asking for one.
82
+ *
83
+ * Nothing has been sent to Jira when this refuses.
84
+ */
85
+ function assertMutationMatchesInput(plan) {
86
+ let derived;
87
+ try {
88
+ // Through validateInput first: the whitelist is part of the derivation, so
89
+ // a plan carrying a field JAM does not write is refused here rather than
90
+ // sent. Re-deriving without it would let an edited input past the check
91
+ // planning applied to it.
92
+ const input = validateInput(plan.operation, plan.input);
93
+ derived = deriveMutation(plan, input);
94
+ }
95
+ catch (err) {
96
+ // A plan whose input no longer derives anything is not a plan. Say that,
97
+ // rather than letting the original refusal read as a fresh request being
98
+ // rejected.
99
+ throw new JamError("JAM_WRITE_PLAN_TAMPERED", `This write plan does not describe a change JAM would make for ${plan.issueKey}. Nothing was written - call jira_write_plan again.`, { planId: plan.planId, issueKey: plan.issueKey, reason: toJamError(err).code });
100
+ }
101
+ if (JSON.stringify(derived) !== JSON.stringify(plan.mutation)) {
102
+ throw new JamError("JAM_WRITE_PLAN_TAMPERED", `This write plan's recorded change does not match what its input produces for ${plan.issueKey}. Nothing was written - call jira_write_plan again.`, { planId: plan.planId, issueKey: plan.issueKey });
103
+ }
104
+ }
105
+ /**
106
+ * The mutation this plan's input produces, without asking Jira anything.
107
+ *
108
+ * Planning calls Jira for two of these - the transition list, the user
109
+ * directory - and records what it settled on. Re-deriving here reads those
110
+ * recorded answers rather than fetching them again, for two reasons: apply
111
+ * would otherwise pay a round trip it does not need, and planning's lookups
112
+ * carry refusals that belong to planning (`already assigned`, `not
113
+ * assignable`). Running those a second time would report a check on the
114
+ * current state as if the plan were malformed.
115
+ *
116
+ * What is being checked is narrower and enough: the mutation must be the one
117
+ * this plan's own parts describe. An edit to `mutation` alone is caught. An
118
+ * edit that also rewrites the input and the recorded resolution is a different
119
+ * plan, obtainable by asking for one - and Jira still has to accept it.
120
+ */
121
+ function deriveMutation(plan, input) {
122
+ switch (plan.operation) {
123
+ case "comment.add":
124
+ return { kind: "comment", text: input.text };
125
+ case "field.update":
126
+ return { kind: "fields", fields: toJiraFields(input) };
127
+ case "status.transition": {
128
+ // The id came from Jira at plan time and is recorded; what is verified is
129
+ // that the plan still points at its own resolution.
130
+ const transition = plan.transition;
131
+ if (!transition)
132
+ throw new JamError("CONFIG_INVALID", "A transition plan must record its transition.");
133
+ return { kind: "transition", transitionId: transition.id };
134
+ }
135
+ case "assignee.update": {
136
+ const target = plan.intendedAfter["assignee"];
137
+ if (!target?.accountId) {
138
+ throw new JamError("CONFIG_INVALID", "An assignment plan must record who it resolved to.");
139
+ }
140
+ return { kind: "assignee", accountId: target.accountId };
141
+ }
142
+ }
143
+ }
63
144
  /**
64
145
  * Re-derive the premises the revision check does not cover.
65
146
  *
@@ -42,3 +42,14 @@ export declare function configuredProject(deps: JamDeps): string;
42
42
  * one it described.
43
43
  */
44
44
  export declare function validateCreateInput(raw: Record<string, unknown>): CreateIssueInput;
45
+ /** Whitelisted values to the shapes Jira's create API expects. */
46
+ /**
47
+ * Exported because apply derives these again and compares - see
48
+ * `assertCreateMutationMatchesInput` in apply-create-issue.ts. Planning and
49
+ * applying must produce the same fields from the same input, so they call one
50
+ * function rather than two that are supposed to agree.
51
+ */
52
+ export declare function toJiraCreateFields(projectKey: string, issueTypeId: string, input: CreateIssueInput, resolved: {
53
+ priority?: string;
54
+ components?: string[];
55
+ }): Record<string, unknown>;
@@ -86,6 +86,7 @@ export async function planCreateIssue(deps, request) {
86
86
  kind: "create",
87
87
  fields: toJiraCreateFields(projectKey, issueType.id, input, { priority, components }),
88
88
  },
89
+ input: request.input,
89
90
  });
90
91
  return {
91
92
  plan,
@@ -169,7 +170,13 @@ function notAllowed(message) {
169
170
  return new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", message, { operation: "issue.create" });
170
171
  }
171
172
  /** Whitelisted values to the shapes Jira's create API expects. */
172
- function toJiraCreateFields(projectKey, issueTypeId, input, resolved) {
173
+ /**
174
+ * Exported because apply derives these again and compares - see
175
+ * `assertCreateMutationMatchesInput` in apply-create-issue.ts. Planning and
176
+ * applying must produce the same fields from the same input, so they call one
177
+ * function rather than two that are supposed to agree.
178
+ */
179
+ export function toJiraCreateFields(projectKey, issueTypeId, input, resolved) {
173
180
  const fields = {
174
181
  project: { key: projectKey },
175
182
  issuetype: { id: issueTypeId },
@@ -1,6 +1,6 @@
1
1
  import type { JamDeps } from "../deps.js";
2
2
  import type { FullIssueContext } from "../domain/context.js";
3
- import type { WritePlan, WritePlanReceipt } from "../domain/write.js";
3
+ import type { ExistingIssueOperation, FieldUpdateInput, WriteInput, WritePlan, WritePlanReceipt } from "../domain/write.js";
4
4
  export type PlanWriteRequest = {
5
5
  /** Absent for `issue.create`, which names a project rather than an issue. */
6
6
  key?: string;
@@ -52,3 +52,14 @@ export type IssueSnapshot = {
52
52
  assigneeAccountId?: string;
53
53
  };
54
54
  export declare function readIssue(deps: JamDeps, issueKey: string): Promise<IssueSnapshot>;
55
+ /**
56
+ * Check the request against the contract, and normalize it.
57
+ *
58
+ * Pure: no Jira, no state. Whether an operation is supported, whether a field
59
+ * is writable and whether the input is even the right shape are all knowable
60
+ * without asking Jira anything, so they are answered first.
61
+ */
62
+ export declare function validateInput(operation: ExistingIssueOperation, raw: Record<string, unknown>): WriteInput;
63
+ /** Whitelisted values to the shapes Jira's field API expects. */
64
+ /** Exported so apply can derive the same fields again and compare. */
65
+ export declare function toJiraFields(input: FieldUpdateInput): Record<string, unknown>;
@@ -49,6 +49,7 @@ export async function planWrite(deps, request) {
49
49
  ...(transition ? { transition } : {}),
50
50
  ...(baseAssigneeAccountId ? { baseAssigneeAccountId } : {}),
51
51
  mutation,
52
+ input: input,
52
53
  });
53
54
  return {
54
55
  plan,
@@ -125,7 +126,7 @@ function requireIssueKey(request) {
125
126
  * is writable and whether the input is even the right shape are all knowable
126
127
  * without asking Jira anything, so they are answered first.
127
128
  */
128
- function validateInput(operation, raw) {
129
+ export function validateInput(operation, raw) {
129
130
  switch (operation) {
130
131
  case "comment.add": {
131
132
  const text = raw.text;
@@ -256,7 +257,8 @@ function currentValue(issue, field) {
256
257
  }
257
258
  }
258
259
  /** Whitelisted values to the shapes Jira's field API expects. */
259
- function toJiraFields(input) {
260
+ /** Exported so apply can derive the same fields again and compare. */
261
+ export function toJiraFields(input) {
260
262
  const fields = {};
261
263
  if (input.summary !== undefined)
262
264
  fields["summary"] = input.summary;
@@ -1,29 +1,25 @@
1
1
  import type { NewWritePlan, WritePlan } from "../domain/write.js";
2
- /**
3
- * Where a plan lives between `jira_write_plan` and `jira_write_apply`.
4
- *
5
- * In this process, and nowhere else. The alternative considered was a signed
6
- * self-contained token, and it is worse here on both counts that matter: the
7
- * signing key would have to come from somewhere (a new secret on disk, or a
8
- * per-process key that gives the token exactly this lifetime anyway), and the
9
- * mutation would have to travel through the agent to come back. Keeping the
10
- * mutation in memory makes forgery impossible rather than merely hard - a
11
- * `planId` is an opaque handle, and what it names never leaves this process.
12
- *
13
- * The cost is that plans do not survive a restart. That is acceptable: a plan
14
- * is only valid while the issue has not moved, so a stale one was going to be
15
- * rejected on its own terms, and re-planning is a single read.
16
- *
17
- * See docs/decisions/adr-jira-write-plane.md.
18
- */
2
+ export type WritePlanStoreOptions = {
3
+ /** Injected by tests so expiry does not depend on wall-clock timing. */
4
+ now?: () => Date;
5
+ /** Injected by tests so no suite writes into the real `~/.jam`. */
6
+ root?: string;
7
+ };
19
8
  export declare class WritePlanStore {
20
9
  private readonly now;
21
- private readonly plans;
22
- /** Injected by tests so expiry does not depend on wall-clock timing. */
23
- constructor(now?: () => Date);
10
+ private readonly root;
11
+ constructor(options?: WritePlanStoreOptions | (() => Date));
24
12
  create(plan: NewWritePlan): WritePlan;
25
13
  /**
26
- * Resolve a plan for applying.
14
+ * Resolve a plan for applying, and claim it in the same step.
15
+ *
16
+ * The claim is a rename, because two processes can now reach the same plan
17
+ * and `rename` is the only thing here that is atomic. Whoever renames it owns
18
+ * the apply; everyone else sees the file already gone and is told there is no
19
+ * such plan - which is the truth, for them.
20
+ *
21
+ * Reading and then deleting would leave a window between the two, and for
22
+ * `comment.add` that window is a second comment.
27
23
  *
28
24
  * An expired plan is reported as expired rather than as missing: those are
29
25
  * different situations, and telling them apart is the difference between
@@ -35,8 +31,18 @@ export declare class WritePlanStore {
35
31
  *
36
32
  * Single use, so a receipt cannot be turned into a second mutation by
37
33
  * calling apply again with the same id - which for `comment.add` would mean
38
- * two comments.
34
+ * two comments. `take` already made that true by claiming the file; this
35
+ * removes the claim so it does not sit until expiry.
39
36
  */
40
37
  consume(planId: string): void;
38
+ private notFound;
39
+ /** `<planId><suffix>`, with the id checked so it can only name a file here. */
40
+ private pathFor;
41
+ private ensureRoot;
42
+ /**
43
+ * Drop what has aged out, including claims abandoned by a process that died
44
+ * mid-apply. Not retrying such a claim is deliberate: apply may already have
45
+ * reached Jira, and that is `JAM_WRITE_UNCERTAIN`'s whole posture.
46
+ */
41
47
  private evictExpired;
42
48
  }
@@ -1,50 +1,92 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import { JamError } from "../domain/errors.js";
3
6
  import { planExpired } from "../policy/write-policy.js";
4
7
  /**
5
- * Where a plan lives between `jira_write_plan` and `jira_write_apply`.
8
+ * Where a plan lives between planning and applying.
6
9
  *
7
- * In this process, and nowhere else. The alternative considered was a signed
8
- * self-contained token, and it is worse here on both counts that matter: the
9
- * signing key would have to come from somewhere (a new secret on disk, or a
10
- * per-process key that gives the token exactly this lifetime anyway), and the
11
- * mutation would have to travel through the agent to come back. Keeping the
12
- * mutation in memory makes forgery impossible rather than merely hard - a
13
- * `planId` is an opaque handle, and what it names never leaves this process.
10
+ * It used to live in the server process and nowhere else, and the ADR said why:
11
+ * a signed token needs a signing key from somewhere, and keeping the mutation
12
+ * in memory made forgery impossible rather than merely hard.
14
13
  *
15
- * The cost is that plans do not survive a restart. That is acceptable: a plan
16
- * is only valid while the issue has not moved, so a stale one was going to be
17
- * rejected on its own terms, and re-planning is a single read.
14
+ * The same ADR named the condition for revisiting that - "if plans ever need to
15
+ * outlive a process". They do now. `jam jira write-plan` and `jam jira
16
+ * write-apply` are two processes, so an in-memory plan is gone before apply can
17
+ * see it, and a session whose MCP registry is stale has no way to write at all.
18
+ *
19
+ * So a plan is a file under `~/.jam/write-plans/`, shared by both transports.
20
+ * MCP and CLI read the same store, which means a plan made through the tools
21
+ * can be applied from the shell when the tools stop answering - that is the
22
+ * whole point of the change.
23
+ *
24
+ * What replaces the in-process guarantee is in apply, not here: it re-derives
25
+ * the mutation from the plan's own inputs and refuses if the stored mutation
26
+ * disagrees. Editing the file to smuggle a different write requires editing the
27
+ * inputs to match, which is just calling plan again. See apply-write.ts.
18
28
  *
19
29
  * See docs/decisions/adr-jira-write-plane.md.
20
30
  */
31
+ /** One plan, one file. The suffix is the claim: see `take`. */
32
+ const PENDING = ".json";
33
+ const CLAIMED = ".claimed";
21
34
  export class WritePlanStore {
22
35
  now;
23
- plans = new Map();
24
- /** Injected by tests so expiry does not depend on wall-clock timing. */
25
- constructor(now = () => new Date()) {
26
- this.now = now;
36
+ root;
37
+ constructor(options = {}) {
38
+ // The old signature took `now` positionally and tests still use it.
39
+ const opts = typeof options === "function" ? { now: options } : options;
40
+ this.now = opts.now ?? (() => new Date());
41
+ this.root = opts.root ?? join(homedir(), ".jam", "write-plans");
27
42
  }
28
43
  create(plan) {
29
44
  this.evictExpired();
30
45
  const stored = { ...plan, planId: randomUUID() };
31
- this.plans.set(stored.planId, stored);
46
+ this.ensureRoot();
47
+ const path = this.pathFor(stored.planId, PENDING);
48
+ writeFileSync(path, JSON.stringify(stored), "utf8");
49
+ // Owner-only. A plan names an issue and what would be written to it, and
50
+ // nothing else on the machine needs to read that.
51
+ chmodSync(path, 0o600);
32
52
  return stored;
33
53
  }
34
54
  /**
35
- * Resolve a plan for applying.
55
+ * Resolve a plan for applying, and claim it in the same step.
56
+ *
57
+ * The claim is a rename, because two processes can now reach the same plan
58
+ * and `rename` is the only thing here that is atomic. Whoever renames it owns
59
+ * the apply; everyone else sees the file already gone and is told there is no
60
+ * such plan - which is the truth, for them.
61
+ *
62
+ * Reading and then deleting would leave a window between the two, and for
63
+ * `comment.add` that window is a second comment.
36
64
  *
37
65
  * An expired plan is reported as expired rather than as missing: those are
38
66
  * different situations, and telling them apart is the difference between
39
67
  * "re-plan" and "you are calling this wrong".
40
68
  */
41
69
  take(planId) {
42
- const plan = this.plans.get(planId);
43
- if (!plan) {
44
- throw new JamError("JAM_WRITE_PLAN_NOT_FOUND", "No such write plan. Plans live in the running JAM server and do not survive a restart - call jira_write_plan again.", { planId });
70
+ const pending = this.pathFor(planId, PENDING);
71
+ const claimed = this.pathFor(planId, CLAIMED);
72
+ try {
73
+ renameSync(pending, claimed);
74
+ }
75
+ catch {
76
+ throw this.notFound(planId);
77
+ }
78
+ let plan;
79
+ try {
80
+ plan = JSON.parse(readFileSync(claimed, "utf8"));
81
+ }
82
+ catch {
83
+ // Unreadable is not "someone else has it" - the file is ours now and it
84
+ // is unusable, so retire it rather than leaving it to expire.
85
+ rmSync(claimed, { force: true });
86
+ throw this.notFound(planId);
45
87
  }
46
88
  if (planExpired(plan.expiresAt, this.now())) {
47
- this.plans.delete(planId);
89
+ rmSync(claimed, { force: true });
48
90
  // What to re-plan against differs by plan: an existing issue has a
49
91
  // current state, a create has only the project's current create schema.
50
92
  // Naming an issue key here for a create would name an issue that has
@@ -66,16 +108,50 @@ export class WritePlanStore {
66
108
  *
67
109
  * Single use, so a receipt cannot be turned into a second mutation by
68
110
  * calling apply again with the same id - which for `comment.add` would mean
69
- * two comments.
111
+ * two comments. `take` already made that true by claiming the file; this
112
+ * removes the claim so it does not sit until expiry.
70
113
  */
71
114
  consume(planId) {
72
- this.plans.delete(planId);
115
+ rmSync(this.pathFor(planId, CLAIMED), { force: true });
73
116
  }
117
+ notFound(planId) {
118
+ return new JamError("JAM_WRITE_PLAN_NOT_FOUND", "No such write plan. A plan is single-use and expires - call jira_write_plan (or `jam jira write-plan`) again.", { planId });
119
+ }
120
+ /** `<planId><suffix>`, with the id checked so it can only name a file here. */
121
+ pathFor(planId, suffix) {
122
+ if (!/^[0-9a-fA-F-]{1,64}$/.test(planId)) {
123
+ throw this.notFound(planId);
124
+ }
125
+ return join(this.root, `${planId}${suffix}`);
126
+ }
127
+ ensureRoot() {
128
+ if (!existsSync(this.root))
129
+ mkdirSync(this.root, { recursive: true, mode: 0o700 });
130
+ }
131
+ /**
132
+ * Drop what has aged out, including claims abandoned by a process that died
133
+ * mid-apply. Not retrying such a claim is deliberate: apply may already have
134
+ * reached Jira, and that is `JAM_WRITE_UNCERTAIN`'s whole posture.
135
+ */
74
136
  evictExpired() {
137
+ if (!existsSync(this.root))
138
+ return;
75
139
  const now = this.now();
76
- for (const [id, plan] of this.plans) {
77
- if (planExpired(plan.expiresAt, now))
78
- this.plans.delete(id);
140
+ for (const name of readdirSync(this.root)) {
141
+ if (!name.endsWith(PENDING) && !name.endsWith(CLAIMED))
142
+ continue;
143
+ const path = join(this.root, name);
144
+ try {
145
+ const plan = JSON.parse(readFileSync(path, "utf8"));
146
+ if (planExpired(plan.expiresAt, now))
147
+ rmSync(path, { force: true });
148
+ }
149
+ catch {
150
+ // Unparseable leftovers are not plans. Removing them keeps the
151
+ // directory from growing without bound, and nothing is lost: a plan
152
+ // that cannot be read cannot be applied either.
153
+ rmSync(path, { force: true });
154
+ }
79
155
  }
80
156
  }
81
157
  }
@@ -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.6.0", "serve"];
16
+ readonly args: readonly ["--yes", "@jam-mcp/launcher@1.7.0", "serve"];
17
17
  };
18
18
  /**
19
19
  * Recognise wiring from before the launcher existed: a hard-coded `node` path
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Writing Jira without the MCP channel.
3
+ *
4
+ * The companion to cli/jira-read.ts, for the same reason and with the same
5
+ * shape. A session whose MCP registry came up in a failed state cannot be shown
6
+ * the tools - Claude Code has no reload surface for a running session - so JAM,
7
+ * its credentials and its Jira access can all be healthy while `jira_write_plan`
8
+ * is simply unreachable. Reads were addressed to the shell first; this is the
9
+ * other half.
10
+ *
11
+ * Nothing here is a second write path. `write-plan` and `write-apply` call the
12
+ * same `planWrite` / `applyWritePlan` the tools call, with the same deps, the
13
+ * same project binding, the same whitelist and the same plan store - so a plan
14
+ * made through MCP can be applied here, which is the point when MCP is what
15
+ * broke.
16
+ *
17
+ * The two-call contract is not relaxed for the shell:
18
+ *
19
+ * write-plan reads Jira, decides what is possible, writes nothing
20
+ * write-apply takes a planId and nothing else, then reads the result back
21
+ *
22
+ * There is deliberately no `jam jira comment` or `jam jira close`. A shortcut
23
+ * that skips planning is exactly what the write plane exists to prevent, and
24
+ * being on a terminal does not change that.
25
+ *
26
+ * Contract, enforced by tests:
27
+ * stdout - one JSON document and nothing else, no ANSI, no prompts
28
+ * stderr - diagnostics only
29
+ *
30
+ * `write-apply` never asks for confirmation. What to write was settled when the
31
+ * plan was made; a prompt here would be a second approval step, which is a
32
+ * different feature and not this one.
33
+ */
34
+ import { type BuildDepsOptions, type JamDeps } from "../deps.js";
35
+ export declare const JIRA_WRITE_USAGE: string;
36
+ export type JiraWriteOptions = BuildDepsOptions & {
37
+ /** Injected by tests so no test reaches a real Jira. */
38
+ deps?: JamDeps;
39
+ /** Where the JSON document goes. Defaults to stdout. */
40
+ write?: (text: string) => void;
41
+ /** Where diagnostics go. Defaults to stderr. */
42
+ warn?: (text: string) => void;
43
+ };
44
+ export declare function runJiraWrite(argv: readonly string[], options?: JiraWriteOptions): Promise<number>;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Writing Jira without the MCP channel.
3
+ *
4
+ * The companion to cli/jira-read.ts, for the same reason and with the same
5
+ * shape. A session whose MCP registry came up in a failed state cannot be shown
6
+ * the tools - Claude Code has no reload surface for a running session - so JAM,
7
+ * its credentials and its Jira access can all be healthy while `jira_write_plan`
8
+ * is simply unreachable. Reads were addressed to the shell first; this is the
9
+ * other half.
10
+ *
11
+ * Nothing here is a second write path. `write-plan` and `write-apply` call the
12
+ * same `planWrite` / `applyWritePlan` the tools call, with the same deps, the
13
+ * same project binding, the same whitelist and the same plan store - so a plan
14
+ * made through MCP can be applied here, which is the point when MCP is what
15
+ * broke.
16
+ *
17
+ * The two-call contract is not relaxed for the shell:
18
+ *
19
+ * write-plan reads Jira, decides what is possible, writes nothing
20
+ * write-apply takes a planId and nothing else, then reads the result back
21
+ *
22
+ * There is deliberately no `jam jira comment` or `jam jira close`. A shortcut
23
+ * that skips planning is exactly what the write plane exists to prevent, and
24
+ * being on a terminal does not change that.
25
+ *
26
+ * Contract, enforced by tests:
27
+ * stdout - one JSON document and nothing else, no ANSI, no prompts
28
+ * stderr - diagnostics only
29
+ *
30
+ * `write-apply` never asks for confirmation. What to write was settled when the
31
+ * plan was made; a prompt here would be a second approval step, which is a
32
+ * different feature and not this one.
33
+ */
34
+ import { applyWritePlan } from "../application/apply-write.js";
35
+ import { planWrite } from "../application/plan-write.js";
36
+ import { buildDeps } from "../deps.js";
37
+ import { toJamError } from "../domain/errors.js";
38
+ import { WRITE_OPERATIONS } from "../domain/write.js";
39
+ export const JIRA_WRITE_USAGE = `Usage:
40
+ jam jira write-plan --operation <op> [--key KEY] --input '<json>'
41
+ jam jira write-apply <planId>
42
+
43
+ Operations: ${WRITE_OPERATIONS.join(", ")}
44
+ issue.create takes no --key; every other operation needs one.
45
+
46
+ Two calls, always. write-plan changes nothing and returns a planId; write-apply
47
+ takes that planId and nothing else. Output is one JSON document on stdout - the
48
+ same receipt the MCP tools return, for a session that cannot see them.
49
+ `;
50
+ /** `--flag value` / `--flag=value`, and nothing invented when absent. */
51
+ function flagValue(argv, flag) {
52
+ const index = argv.indexOf(flag);
53
+ if (index >= 0)
54
+ return argv[index + 1];
55
+ const inline = argv.find((arg) => arg.startsWith(`${flag}=`));
56
+ return inline ? inline.slice(flag.length + 1) : undefined;
57
+ }
58
+ const FLAGS_WITH_VALUES = ["--operation", "--key", "--input"];
59
+ const positional = (argv) => {
60
+ const out = [];
61
+ for (let i = 0; i < argv.length; i += 1) {
62
+ const arg = argv[i];
63
+ if (FLAGS_WITH_VALUES.includes(arg)) {
64
+ i += 1;
65
+ continue;
66
+ }
67
+ if (arg.startsWith("--"))
68
+ continue;
69
+ out.push(arg);
70
+ }
71
+ return out;
72
+ };
73
+ export async function runJiraWrite(argv, options = {}) {
74
+ const write = options.write ?? ((text) => process.stdout.write(text));
75
+ const warn = options.warn ?? ((text) => process.stderr.write(text));
76
+ const [subcommand, ...rest] = argv;
77
+ if (subcommand !== "write-plan" && subcommand !== "write-apply") {
78
+ warn(`Unknown jira command: ${subcommand ?? "(none)"}\n\n${JIRA_WRITE_USAGE}`);
79
+ return 1;
80
+ }
81
+ // Argument shape is checked here; what the values mean is checked by the
82
+ // application, which is the same judgement the tools get. This file does not
83
+ // decide what a valid operation or input is.
84
+ let request;
85
+ if (subcommand === "write-plan") {
86
+ const operation = flagValue(rest, "--operation");
87
+ if (!operation) {
88
+ warn(`jam jira write-plan needs --operation.\n\n${JIRA_WRITE_USAGE}`);
89
+ return 1;
90
+ }
91
+ const raw = flagValue(rest, "--input");
92
+ if (raw === undefined) {
93
+ warn(`jam jira write-plan needs --input '<json>'.\n\n${JIRA_WRITE_USAGE}`);
94
+ return 1;
95
+ }
96
+ let input;
97
+ try {
98
+ input = JSON.parse(raw);
99
+ }
100
+ catch {
101
+ warn("--input must be a JSON object.\n");
102
+ return 1;
103
+ }
104
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
105
+ warn("--input must be a JSON object.\n");
106
+ return 1;
107
+ }
108
+ const key = flagValue(rest, "--key");
109
+ request = {
110
+ kind: "plan",
111
+ ...(key ? { key } : {}),
112
+ operation,
113
+ input: input,
114
+ };
115
+ }
116
+ else {
117
+ // Exactly one positional and no way to pass anything else. There is no
118
+ // payload flag here, and adding one would be adding a way to write
119
+ // something the plan did not describe.
120
+ const [planId, ...extra] = positional(rest);
121
+ if (!planId || extra.length > 0) {
122
+ warn(`jam jira write-apply takes exactly one planId.\n\n${JIRA_WRITE_USAGE}`);
123
+ return 1;
124
+ }
125
+ request = { kind: "apply", planId };
126
+ }
127
+ try {
128
+ const { deps: injected, write: _w, warn: _n, ...depsOptions } = options;
129
+ const deps = injected ?? (await buildDeps(depsOptions));
130
+ const result = request.kind === "plan"
131
+ ? (await planWrite(deps, {
132
+ ...(request.key !== undefined ? { key: request.key } : {}),
133
+ operation: request.operation,
134
+ input: request.input,
135
+ })).receipt
136
+ : await applyWritePlan(deps, { planId: request.planId });
137
+ write(`${JSON.stringify(result)}\n`);
138
+ return 0;
139
+ }
140
+ catch (err) {
141
+ // The same normalized codes the tools produce - an agent reads one contract,
142
+ // not two.
143
+ write(`${JSON.stringify(toJamError(err).toPayload())}\n`);
144
+ return 1;
145
+ }
146
+ }
@@ -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\nLifecycle (the same words ASC uses)\n jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]\n Wire up this project and verify it. Binds it to you\n alone, writing nothing to the repository; --shared\n adopts JAM for the team (project.yaml, .mcp.json)\n jam status What is configured, what works, what is blocked\n jam update Move this machine's registration to the published release\n jam refresh Keep the version; re-register what this build owns\n jam uninstall Remove JAM's registrations; your bindings and credentials stay\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\nJira\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\nAuthentication\n jam auth status [--json] Whether Jira credentials are configured (never their value)\n jam auth login Store them 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 status --json Health check as structured output\n jam update check|plan [--json]\n jam refresh check|plan [--json]\n jam uninstall plan [--json]\n\nHost runtime\n jam serve Run the MCP server over stdio - this is what Claude\n Code and Codex launch. Not a command a person types.\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\nLifecycle (the same words ASC uses)\n jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]\n Wire up this project and verify it. Binds it to you\n alone, writing nothing to the repository; --shared\n adopts JAM for the team (project.yaml, .mcp.json)\n jam status What is configured, what works, what is blocked\n jam update Move this machine's registration to the published release\n jam refresh Keep the version; re-register what this build owns\n jam uninstall Remove JAM's registrations; your bindings and credentials stay\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\nJira\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 jam jira write-plan --operation <op> [--key KEY] --input '<json>'\n jam jira write-apply <planId>\n Write the same way the tools do: plan first, then\n apply that plan by id. Nothing is written until\n write-apply, and it takes no payload of its own\n\nAuthentication\n jam auth status [--json] Whether Jira credentials are configured (never their value)\n jam auth login Store them 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 status --json Health check as structured output\n jam update check|plan [--json]\n jam refresh check|plan [--json]\n jam uninstall plan [--json]\n\nHost runtime\n jam serve Run the MCP server over stdio - this is what Claude\n Code and Codex launch. Not a command a person types.\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
@@ -9,6 +9,7 @@ import { runSetupWizard } from "./cli/setup-wizard.js";
9
9
  import { reportPromptError, Ui } from "./cli/ui.js";
10
10
  import { authStatusCommand, doctorJsonCommand, setupAgentCommand, setupApplyCommand, setupPlanCommand, } from "./cli/agent-api.js";
11
11
  import { runJiraRead } from "./cli/jira-read.js";
12
+ import { runJiraWrite } from "./cli/jira-write.js";
12
13
  /**
13
14
  * Command dispatch for the JAM CLI, separated from the bin so other entry
14
15
  * points (notably @jam-mcp/bootstrap) can forward to exactly these commands
@@ -35,6 +36,11 @@ Jira
35
36
  jam jira full <KEY> [KEY...]
36
37
  Read Jira from the shell - the same reads the MCP
37
38
  tools do, for a session that cannot see them yet
39
+ jam jira write-plan --operation <op> [--key KEY] --input '<json>'
40
+ jam jira write-apply <planId>
41
+ Write the same way the tools do: plan first, then
42
+ apply that plan by id. Nothing is written until
43
+ write-apply, and it takes no payload of its own
38
44
 
39
45
  Authentication
40
46
  jam auth status [--json] Whether Jira credentials are configured (never their value)
@@ -155,9 +161,12 @@ export async function runJamCommand(argv) {
155
161
  return 1;
156
162
  }
157
163
  case "jira":
158
- // Reads addressed to the shell, for a session that cannot see the MCP
159
- // tools yet. Same application path as the tools - see cli/jira-read.ts.
160
- return runJiraRead(rest);
164
+ // Jira addressed to the shell, for a session that cannot see the MCP
165
+ // tools. Same application path as the tools, reads and writes alike -
166
+ // see cli/jira-read.ts and cli/jira-write.ts.
167
+ return rest[0] === "write-plan" || rest[0] === "write-apply"
168
+ ? runJiraWrite(rest)
169
+ : runJiraRead(rest);
161
170
  case "help":
162
171
  case "--help":
163
172
  case "-h":
@@ -5,7 +5,7 @@
5
5
  * is mapped onto one of these codes so the agent (and `jam doctor`) can reason
6
6
  * about failures without parsing vendor-specific payloads.
7
7
  */
8
- export declare const JAM_ERROR_CODES: readonly ["JIRA_AUTH_FAILED", "JIRA_PERMISSION_DENIED", "JQL_INVALID", "ISSUE_NOT_FOUND", "RATE_LIMITED", "CONTEXT_TOO_LARGE", "PARTIAL_RESULT", "CONFIG_INVALID", "JIRA_UNAVAILABLE", "JAM_SETUP_REQUIRED", "JAM_BINDINGS_UNREADABLE", "JAM_WRITE_SCOPE_VIOLATION", "JAM_WRITE_OPERATION_NOT_ALLOWED", "JAM_WRITE_FIELD_NOT_ALLOWED", "JAM_WRITE_TRANSITION_NOT_AVAILABLE", "JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE", "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED", "JAM_WRITE_VALUE_NOT_ALLOWED", "JAM_WRITE_SCHEMA_CHANGED", "JAM_WRITE_ASSIGNEE_NOT_FOUND", "JAM_WRITE_ASSIGNEE_AMBIGUOUS", "JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE", "JAM_WRITE_ASSIGNEE_ALREADY_SET", "JAM_WRITE_PLAN_NOT_FOUND", "JAM_WRITE_PLAN_EXPIRED", "JAM_WRITE_CONFLICT", "JAM_WRITE_VERIFICATION_FAILED", "JAM_WRITE_UNCERTAIN"];
8
+ export declare const JAM_ERROR_CODES: readonly ["JIRA_AUTH_FAILED", "JIRA_PERMISSION_DENIED", "JQL_INVALID", "ISSUE_NOT_FOUND", "RATE_LIMITED", "CONTEXT_TOO_LARGE", "PARTIAL_RESULT", "CONFIG_INVALID", "JIRA_UNAVAILABLE", "JAM_SETUP_REQUIRED", "JAM_BINDINGS_UNREADABLE", "JAM_WRITE_SCOPE_VIOLATION", "JAM_WRITE_OPERATION_NOT_ALLOWED", "JAM_WRITE_FIELD_NOT_ALLOWED", "JAM_WRITE_TRANSITION_NOT_AVAILABLE", "JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE", "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED", "JAM_WRITE_VALUE_NOT_ALLOWED", "JAM_WRITE_SCHEMA_CHANGED", "JAM_WRITE_ASSIGNEE_NOT_FOUND", "JAM_WRITE_ASSIGNEE_AMBIGUOUS", "JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE", "JAM_WRITE_ASSIGNEE_ALREADY_SET", "JAM_WRITE_PLAN_NOT_FOUND", "JAM_WRITE_PLAN_EXPIRED", "JAM_WRITE_PLAN_TAMPERED", "JAM_WRITE_CONFLICT", "JAM_WRITE_VERIFICATION_FAILED", "JAM_WRITE_UNCERTAIN"];
9
9
  export type JamErrorCode = (typeof JAM_ERROR_CODES)[number];
10
10
  export type JamErrorPayload = {
11
11
  error: {
@@ -44,6 +44,11 @@ export const JAM_ERROR_CODES = [
44
44
  "JAM_WRITE_ASSIGNEE_ALREADY_SET",
45
45
  "JAM_WRITE_PLAN_NOT_FOUND",
46
46
  "JAM_WRITE_PLAN_EXPIRED",
47
+ // A stored plan that does not describe a change JAM would make. Plans live in
48
+ // a file so both transports can reach one, and apply re-derives the mutation
49
+ // from the plan's own input rather than trusting what the file says. Distinct
50
+ // from CONFLICT: nothing moved, the plan itself does not hold up.
51
+ "JAM_WRITE_PLAN_TAMPERED",
47
52
  "JAM_WRITE_CONFLICT",
48
53
  "JAM_WRITE_VERIFICATION_FAILED",
49
54
  "JAM_WRITE_UNCERTAIN",
@@ -161,6 +161,18 @@ type WritePlanCommon = {
161
161
  expiresAt: string;
162
162
  /** Normalized payload the apply step will send. Never supplied by a caller. */
163
163
  mutation: WriteMutation;
164
+ /**
165
+ * The validated input the mutation was derived from.
166
+ *
167
+ * Kept so apply can derive the mutation again and compare. Plans live in a
168
+ * file now, and a file can be edited - re-deriving is what makes that
169
+ * pointless: changing the mutation without changing the input is caught, and
170
+ * changing both is just calling plan again.
171
+ *
172
+ * Validated, not raw: what a caller sent has already been checked against the
173
+ * operation's whitelist by the time it lands here.
174
+ */
175
+ input: Record<string, unknown>;
164
176
  };
165
177
  /**
166
178
  * A plan against an issue that already exists.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jam-mcp/server",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "JAM (Jira Agent MCP) - agent-facing Jira access layer: MCP server, setup core, and CLI",
5
5
  "keywords": [
6
6
  "jira",
@@ -41,7 +41,7 @@
41
41
  "test:watch": "vitest"
42
42
  },
43
43
  "dependencies": {
44
- "@jam-mcp/launcher": "1.6.0",
44
+ "@jam-mcp/launcher": "1.7.0",
45
45
  "@modelcontextprotocol/sdk": "^1.30.0",
46
46
  "yaml": "^2.9.0",
47
47
  "zod": "^4.4.3"