@jam-mcp/server 1.0.1 → 1.2.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.
Files changed (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +86 -72
  3. package/dist/adapters/credentials/windows-user-env.d.ts +3 -1
  4. package/dist/adapters/credentials/windows-user-env.js +20 -1
  5. package/dist/adapters/jira-cloud/jira-client.d.ts +10 -1
  6. package/dist/adapters/jira-cloud/jira-client.js +1 -1
  7. package/dist/adapters/jira-cloud/jira-create-metadata.adapter.d.ts +27 -0
  8. package/dist/adapters/jira-cloud/jira-create-metadata.adapter.js +81 -0
  9. package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +10 -1
  10. package/dist/adapters/jira-cloud/jira-read.adapter.js +17 -0
  11. package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +41 -6
  12. package/dist/adapters/jira-cloud/jira-write.adapter.js +96 -11
  13. package/dist/application/apply-create-issue.d.ts +20 -0
  14. package/dist/application/apply-create-issue.js +187 -0
  15. package/dist/application/apply-write.d.ts +25 -0
  16. package/dist/application/apply-write.js +166 -0
  17. package/dist/application/plan-create-issue.d.ts +44 -0
  18. package/dist/application/plan-create-issue.js +188 -0
  19. package/dist/application/plan-write.d.ts +39 -0
  20. package/dist/application/plan-write.js +210 -0
  21. package/dist/application/write-plan-store.d.ts +42 -0
  22. package/dist/application/write-plan-store.js +81 -0
  23. package/dist/bootstrap/boot-health-gate.js +2 -2
  24. package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
  25. package/dist/cli/auth.d.ts +6 -0
  26. package/dist/cli/auth.js +2 -1
  27. package/dist/cli-entry.js +33 -33
  28. package/dist/deps.d.ts +26 -0
  29. package/dist/deps.js +14 -0
  30. package/dist/domain/adf.d.ts +35 -0
  31. package/dist/domain/adf.js +65 -0
  32. package/dist/domain/errors.d.ts +1 -1
  33. package/dist/domain/errors.js +22 -0
  34. package/dist/domain/write.d.ts +230 -0
  35. package/dist/domain/write.js +65 -0
  36. package/dist/mcp/create-server.d.ts +12 -3
  37. package/dist/mcp/create-server.js +35 -6
  38. package/dist/mcp/tools/jira-write-apply.tool.d.ts +3 -0
  39. package/dist/mcp/tools/jira-write-apply.tool.js +33 -0
  40. package/dist/mcp/tools/jira-write-plan.tool.d.ts +3 -0
  41. package/dist/mcp/tools/jira-write-plan.tool.js +85 -0
  42. package/dist/policy/consistency-policy.d.ts +10 -4
  43. package/dist/policy/create-policy.d.ts +86 -0
  44. package/dist/policy/create-policy.js +182 -0
  45. package/dist/policy/write-policy.d.ts +69 -0
  46. package/dist/policy/write-policy.js +128 -0
  47. package/dist/ports/jira-create-metadata.port.d.ts +25 -0
  48. package/dist/ports/jira-create-metadata.port.js +1 -0
  49. package/dist/ports/jira-read.port.d.ts +23 -0
  50. package/dist/ports/jira-write.port.d.ts +27 -4
  51. package/package.json +69 -69
@@ -17,6 +17,28 @@ export const JAM_ERROR_CODES = [
17
17
  "JIRA_UNAVAILABLE",
18
18
  "JAM_SETUP_REQUIRED",
19
19
  "JAM_BINDINGS_UNREADABLE",
20
+ // Write plane. Each names a distinct decision an agent has to make, which is
21
+ // why none of them collapse into CONFIG_INVALID: "you may not write there",
22
+ // "someone else moved it", and "we could not confirm it happened" call for
23
+ // three different next steps.
24
+ "JAM_WRITE_SCOPE_VIOLATION",
25
+ "JAM_WRITE_OPERATION_NOT_ALLOWED",
26
+ "JAM_WRITE_FIELD_NOT_ALLOWED",
27
+ "JAM_WRITE_TRANSITION_NOT_AVAILABLE",
28
+ // Creation. Each is a refusal JAM makes before Jira is asked to act, or a
29
+ // premise that stopped holding between planning and applying - never a raw
30
+ // Jira 400 passed along. "That type is not on offer", "this project needs a
31
+ // field JAM cannot fill" and "the schema moved under the plan" are three
32
+ // different next steps for whoever is holding the agent.
33
+ "JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE",
34
+ "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED",
35
+ "JAM_WRITE_VALUE_NOT_ALLOWED",
36
+ "JAM_WRITE_SCHEMA_CHANGED",
37
+ "JAM_WRITE_PLAN_NOT_FOUND",
38
+ "JAM_WRITE_PLAN_EXPIRED",
39
+ "JAM_WRITE_CONFLICT",
40
+ "JAM_WRITE_VERIFICATION_FAILED",
41
+ "JAM_WRITE_UNCERTAIN",
20
42
  ];
21
43
  export class JamError extends Error {
22
44
  code;
@@ -0,0 +1,230 @@
1
+ /**
2
+ * The write contract.
3
+ *
4
+ * JAM's write plane is not a Jira REST proxy. An agent cannot hand JAM a
5
+ * mutation and have it forwarded; it describes an intent, JAM works out whether
6
+ * that intent is currently possible, and only a plan JAM itself produced can be
7
+ * applied. Everything in this file exists to keep that shape: a closed set of
8
+ * operations, a fixed field whitelist, and a plan that records what the issue
9
+ * looked like when the plan was made.
10
+ *
11
+ * Read semantics are deliberately not reused here. A read result's `meta`
12
+ * answers "how complete was this retrieval"; a write result answers "did this
13
+ * change happen, and did we see it happen". Mixing them would let a confident
14
+ * `complete: true` stand in for a verified mutation.
15
+ */
16
+ /**
17
+ * Operations that change an issue that already exists.
18
+ *
19
+ * Kept apart from creation because the two have different shapes at every
20
+ * layer: these name an issue, creation names a project; these compare a
21
+ * revision to detect a conflict, creation has no revision to compare.
22
+ */
23
+ export declare const EXISTING_ISSUE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition"];
24
+ /** The operations the public MCP surface accepts. Nothing else is reachable. */
25
+ export declare const WRITE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition", "issue.create"];
26
+ export type ExistingIssueOperation = (typeof EXISTING_ISSUE_OPERATIONS)[number];
27
+ export type WriteOperation = (typeof WRITE_OPERATIONS)[number];
28
+ /**
29
+ * Fields `field.update` may touch.
30
+ *
31
+ * A whitelist rather than an open field map: an open map turns every
32
+ * project-specific screen, custom field and permission quirk into a runtime
33
+ * surprise, and makes "what can an agent change" unanswerable. Custom fields
34
+ * and assignee are deliberately absent - both need resolution work (schema
35
+ * discovery, accountId lookup) that belongs in its own round.
36
+ */
37
+ export declare const WRITABLE_FIELDS: readonly ["summary", "priority", "labels", "components"];
38
+ export type WritableField = (typeof WRITABLE_FIELDS)[number];
39
+ export type CommentAddInput = {
40
+ text: string;
41
+ };
42
+ export type FieldUpdateInput = {
43
+ summary?: string;
44
+ priority?: string;
45
+ labels?: string[];
46
+ components?: string[];
47
+ };
48
+ export type StatusTransitionInput = {
49
+ status: string;
50
+ };
51
+ /**
52
+ * Fields `issue.create` may set.
53
+ *
54
+ * The same argument as WRITABLE_FIELDS, and the same answer: a closed list, so
55
+ * "what can an agent create" has an answer that does not depend on one
56
+ * project's screen configuration. `issueType` and `summary` are required by
57
+ * every Jira project JAM can serve; the rest are optional and only sent when
58
+ * asked for.
59
+ */
60
+ export declare const CREATABLE_FIELDS: readonly ["issueType", "summary", "description", "priority", "labels", "components"];
61
+ export type CreatableField = (typeof CREATABLE_FIELDS)[number];
62
+ export type CreateIssueInput = {
63
+ issueType: string;
64
+ summary: string;
65
+ description?: string;
66
+ priority?: string;
67
+ labels?: string[];
68
+ components?: string[];
69
+ };
70
+ export type WriteInput = CommentAddInput | FieldUpdateInput | StatusTransitionInput | CreateIssueInput;
71
+ /** An issue type as Jira offers it for one project, right now. */
72
+ export type CreateIssueType = {
73
+ id: string;
74
+ name: string;
75
+ subtask: boolean;
76
+ };
77
+ /**
78
+ * One field on a project's create screen, as Jira describes it.
79
+ *
80
+ * `allowedValues` is present only for fields Jira constrains (priority,
81
+ * components, and issue-type-scoped pickers). Absent means unconstrained, not
82
+ * empty - the difference decides whether a value can be resolved or must be
83
+ * refused.
84
+ */
85
+ export type CreateFieldMetadata = {
86
+ /** Jira's field id, e.g. `summary` or `customfield_12345`. */
87
+ id: string;
88
+ name: string;
89
+ required: boolean;
90
+ hasDefaultValue: boolean;
91
+ allowedValues?: {
92
+ id?: string;
93
+ name?: string;
94
+ }[];
95
+ };
96
+ /**
97
+ * What a create plan depends on, recorded so apply can check it again.
98
+ *
99
+ * Not a hash of the metadata document: an unrelated optional field appearing
100
+ * on the create screen does not invalidate a plan, and treating it as though
101
+ * it did would make every plan fail on a busy project. What is recorded here
102
+ * is the set of premises the plan was built on, and apply re-derives whether
103
+ * each still holds.
104
+ */
105
+ export type CreateSchemaRequirements = {
106
+ issueTypeId: string;
107
+ issueTypeName: string;
108
+ /** Required field ids JAM undertook to supply or knew Jira would default. */
109
+ requiredFieldIds: string[];
110
+ /**
111
+ * Values resolved from Jira's allowed lists at plan time, by field id. Apply
112
+ * refuses if any of them is no longer offered.
113
+ */
114
+ resolvedValues: {
115
+ fieldId: string;
116
+ requested: string;
117
+ resolved: string;
118
+ }[];
119
+ };
120
+ /** A transition as Jira currently offers it for one issue. */
121
+ export type JiraTransition = {
122
+ id: string;
123
+ name: string;
124
+ /** The status this transition leads to, as Jira names it. */
125
+ to: string;
126
+ };
127
+ /** Fields every plan carries, whatever it is a plan for. */
128
+ type WritePlanCommon = {
129
+ planId: string;
130
+ projectKey: string;
131
+ /** Only the fields this operation touches. */
132
+ before: Record<string, unknown>;
133
+ intendedAfter: Record<string, unknown>;
134
+ createdAt: string;
135
+ expiresAt: string;
136
+ /** Normalized payload the apply step will send. Never supplied by a caller. */
137
+ mutation: WriteMutation;
138
+ };
139
+ /**
140
+ * A plan against an issue that already exists.
141
+ *
142
+ * `baseUpdated` is the issue's `updated` timestamp at plan time. Apply re-reads
143
+ * the issue and refuses when it has moved: a plan that was valid is not the
144
+ * same as a plan that is still valid.
145
+ */
146
+ export type ExistingIssueWritePlan = WritePlanCommon & {
147
+ kind: "existing-issue";
148
+ issueKey: string;
149
+ operation: ExistingIssueOperation;
150
+ baseUpdated: string;
151
+ /**
152
+ * The transition Jira offered for this target status, resolved at plan time.
153
+ * Present only for `status.transition` - a transition id is never guessed
154
+ * from a status name.
155
+ */
156
+ transition?: JiraTransition;
157
+ };
158
+ /**
159
+ * A plan to create an issue that does not exist yet.
160
+ *
161
+ * There is no `issueKey` and no `baseUpdated`, and neither is filled with a
162
+ * placeholder: nothing to name, and no revision to compare. What takes their
163
+ * place is `schemaRequirements` - creation's concurrency boundary is the
164
+ * project's create schema, not one issue's revision, so that is what apply
165
+ * re-checks before it sends anything.
166
+ */
167
+ export type CreateIssueWritePlan = WritePlanCommon & {
168
+ kind: "create-issue";
169
+ operation: "issue.create";
170
+ before: {
171
+ issue: null;
172
+ };
173
+ schemaRequirements: CreateSchemaRequirements;
174
+ };
175
+ export type WritePlan = ExistingIssueWritePlan | CreateIssueWritePlan;
176
+ /** A plan as it is handed to the store, before an id has been minted. */
177
+ export type NewWritePlan = Omit<ExistingIssueWritePlan, "planId"> | Omit<CreateIssueWritePlan, "planId">;
178
+ /** What apply will actually send. Produced by planning, never by an agent. */
179
+ export type WriteMutation = {
180
+ kind: "comment";
181
+ text: string;
182
+ } | {
183
+ kind: "fields";
184
+ fields: Record<string, unknown>;
185
+ } | {
186
+ kind: "transition";
187
+ transitionId: string;
188
+ } | {
189
+ kind: "create";
190
+ fields: Record<string, unknown>;
191
+ };
192
+ /** What `jira_write_plan` returns. The mutation itself is not exposed. */
193
+ export type WritePlanReceipt = {
194
+ status: "planned";
195
+ planId: string;
196
+ operation: WriteOperation;
197
+ before: Record<string, unknown>;
198
+ intendedAfter: Record<string, unknown>;
199
+ expiresAt: string;
200
+ /**
201
+ * The issue this plan changes. Absent for `issue.create`, which has no issue
202
+ * yet - a placeholder key here would be a claim JAM cannot make.
203
+ */
204
+ issue?: string;
205
+ /** The project a new issue would be created in. Present for `issue.create`. */
206
+ project?: string;
207
+ /** How the result of applying this plan will be confirmed. */
208
+ verification: {
209
+ method: "direct-issue-read";
210
+ /** What a direct read must show before JAM calls the write applied. */
211
+ expects: Record<string, unknown>;
212
+ };
213
+ };
214
+ /** What `jira_write_apply` returns once a direct read has confirmed the change. */
215
+ export type WriteApplyReceipt = {
216
+ status: "applied";
217
+ /** For `issue.create`, the key Jira minted - known only after applying. */
218
+ issue: string;
219
+ operation: WriteOperation;
220
+ before: Record<string, unknown>;
221
+ after: Record<string, unknown>;
222
+ /** Always true in an `applied` receipt - an unverified write is an error. */
223
+ verified: true;
224
+ /** Present for `comment.add`: the comment Jira created. */
225
+ commentId?: string;
226
+ };
227
+ export declare function isWriteOperation(value: string): value is WriteOperation;
228
+ export declare function isExistingIssueOperation(value: string): value is ExistingIssueOperation;
229
+ export declare function isWritableField(value: string): value is WritableField;
230
+ export {};
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The write contract.
3
+ *
4
+ * JAM's write plane is not a Jira REST proxy. An agent cannot hand JAM a
5
+ * mutation and have it forwarded; it describes an intent, JAM works out whether
6
+ * that intent is currently possible, and only a plan JAM itself produced can be
7
+ * applied. Everything in this file exists to keep that shape: a closed set of
8
+ * operations, a fixed field whitelist, and a plan that records what the issue
9
+ * looked like when the plan was made.
10
+ *
11
+ * Read semantics are deliberately not reused here. A read result's `meta`
12
+ * answers "how complete was this retrieval"; a write result answers "did this
13
+ * change happen, and did we see it happen". Mixing them would let a confident
14
+ * `complete: true` stand in for a verified mutation.
15
+ */
16
+ /**
17
+ * Operations that change an issue that already exists.
18
+ *
19
+ * Kept apart from creation because the two have different shapes at every
20
+ * layer: these name an issue, creation names a project; these compare a
21
+ * revision to detect a conflict, creation has no revision to compare.
22
+ */
23
+ export const EXISTING_ISSUE_OPERATIONS = [
24
+ "comment.add",
25
+ "field.update",
26
+ "status.transition",
27
+ ];
28
+ /** The operations the public MCP surface accepts. Nothing else is reachable. */
29
+ export const WRITE_OPERATIONS = [...EXISTING_ISSUE_OPERATIONS, "issue.create"];
30
+ /**
31
+ * Fields `field.update` may touch.
32
+ *
33
+ * A whitelist rather than an open field map: an open map turns every
34
+ * project-specific screen, custom field and permission quirk into a runtime
35
+ * surprise, and makes "what can an agent change" unanswerable. Custom fields
36
+ * and assignee are deliberately absent - both need resolution work (schema
37
+ * discovery, accountId lookup) that belongs in its own round.
38
+ */
39
+ export const WRITABLE_FIELDS = ["summary", "priority", "labels", "components"];
40
+ /**
41
+ * Fields `issue.create` may set.
42
+ *
43
+ * The same argument as WRITABLE_FIELDS, and the same answer: a closed list, so
44
+ * "what can an agent create" has an answer that does not depend on one
45
+ * project's screen configuration. `issueType` and `summary` are required by
46
+ * every Jira project JAM can serve; the rest are optional and only sent when
47
+ * asked for.
48
+ */
49
+ export const CREATABLE_FIELDS = [
50
+ "issueType",
51
+ "summary",
52
+ "description",
53
+ "priority",
54
+ "labels",
55
+ "components",
56
+ ];
57
+ export function isWriteOperation(value) {
58
+ return WRITE_OPERATIONS.includes(value);
59
+ }
60
+ export function isExistingIssueOperation(value) {
61
+ return EXISTING_ISSUE_OPERATIONS.includes(value);
62
+ }
63
+ export function isWritableField(value) {
64
+ return WRITABLE_FIELDS.includes(value);
65
+ }
@@ -1,9 +1,18 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { JamDeps } from "../deps.js";
3
3
  export declare const SERVER_NAME = "jam";
4
+ export declare const TOOL_COUNT: number;
4
5
  /**
5
- * The external contract: exactly three read tools, stable from the first
6
- * release. Internal changes (cache, Rovo, remote transport) must not add or
7
- * rename anything here.
6
+ * The external contract: three read tools and two write tools.
7
+ *
8
+ * What JAM can write grows as operations inside `jira_write_plan`, never as
9
+ * tools. `issue.create` arrived that way: an agent that knew the write pair
10
+ * already knew how to reach it.
11
+ *
12
+ * The read three have been stable since the first release and do not change.
13
+ * The write pair is a single operation split in half on purpose - deciding and
14
+ * doing are separate calls, so an agent cannot mutate Jira without first
15
+ * having been shown what it is about to change. Internal changes (cache, Rovo,
16
+ * remote transport) must not add or rename anything here.
8
17
  */
9
18
  export declare function createServer(deps: JamDeps): McpServer;
@@ -3,13 +3,36 @@ import { createRequire } from "node:module";
3
3
  import { registerJiraContext } from "./tools/jira-context.tool.js";
4
4
  import { registerJiraFull } from "./tools/jira-full.tool.js";
5
5
  import { registerJiraSearch } from "./tools/jira-search.tool.js";
6
+ import { registerJiraWriteApply } from "./tools/jira-write-apply.tool.js";
7
+ import { registerJiraWritePlan } from "./tools/jira-write-plan.tool.js";
6
8
  const require = createRequire(import.meta.url);
7
9
  const pkg = require("../../package.json");
8
10
  export const SERVER_NAME = "jam";
9
11
  /**
10
- * The external contract: exactly three read tools, stable from the first
11
- * release. Internal changes (cache, Rovo, remote transport) must not add or
12
- * rename anything here.
12
+ * Every tool registration, in one list. `TOOL_COUNT` is derived from it, so a
13
+ * tool added here is counted everywhere it is reported - doctor included -
14
+ * without anyone having to remember a second place to edit.
15
+ */
16
+ const REGISTER_TOOLS = [
17
+ registerJiraSearch,
18
+ registerJiraContext,
19
+ registerJiraFull,
20
+ registerJiraWritePlan,
21
+ registerJiraWriteApply,
22
+ ];
23
+ export const TOOL_COUNT = REGISTER_TOOLS.length;
24
+ /**
25
+ * The external contract: three read tools and two write tools.
26
+ *
27
+ * What JAM can write grows as operations inside `jira_write_plan`, never as
28
+ * tools. `issue.create` arrived that way: an agent that knew the write pair
29
+ * already knew how to reach it.
30
+ *
31
+ * The read three have been stable since the first release and do not change.
32
+ * The write pair is a single operation split in half on purpose - deciding and
33
+ * doing are separate calls, so an agent cannot mutate Jira without first
34
+ * having been shown what it is about to change. Internal changes (cache, Rovo,
35
+ * remote transport) must not add or rename anything here.
13
36
  */
14
37
  export function createServer(deps) {
15
38
  const server = new McpServer({ name: SERVER_NAME, version: pkg.version ?? "0.0.0" }, {
@@ -23,10 +46,16 @@ export function createServer(deps) {
23
46
  "Every result carries a `meta` block; if meta.complete is false the answer is partial and must be reported as such.",
24
47
  "meta.complete describes JAM's retrieval, not the project: it means the Jira read finished with no known loss, never that Jira holds the whole story.",
25
48
  "meta.evidenceScope and meta.limitations name what was not evaluated - the repository and every external source among them. Judge Jira evidence from these results; judge execution reality elsewhere.",
49
+ "",
50
+ "Writing Jira is two steps: jira_write_plan, then jira_write_apply with the planId it returned.",
51
+ "jira_write_plan changes nothing - it reads the issue, checks the change is possible, and describes what would happen.",
52
+ "jira_write_apply takes only a planId. There is no way to write without planning first, and no payload to override what the plan decided.",
53
+ "Writes are confined to the configured Jira project, and confirmed by reading the issue back. A write JAM could not verify is never reported as done.",
54
+ "jira_write_plan also creates issues: operation \"issue.create\", no key, and no project - the new issue goes into the project this workspace is bound to. Planning reads Jira's create schema first, so an unavailable issue type, a disallowed priority or component, and a create screen requiring a field JAM cannot set are refused before anything is sent.",
55
+ "On JAM_WRITE_CONFLICT or JAM_WRITE_PLAN_EXPIRED, plan again against the current state. On JAM_WRITE_UNCERTAIN, read the issue - never retry the apply, which could apply the change twice.",
26
56
  ].join("\n"),
27
57
  });
28
- registerJiraSearch(server, deps);
29
- registerJiraContext(server, deps);
30
- registerJiraFull(server, deps);
58
+ for (const register of REGISTER_TOOLS)
59
+ register(server, deps);
31
60
  return server;
32
61
  }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { JamDeps } from "../../deps.js";
3
+ export declare function registerJiraWriteApply(server: McpServer, deps: JamDeps): void;
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ import { applyWritePlan } from "../../application/apply-write.js";
3
+ import { runTool } from "../tool-result.js";
4
+ const DESCRIPTION = `Apply a plan from jira_write_plan. This changes Jira.
5
+
6
+ Takes a planId and nothing else. The change was decided when the plan was made, so there is no field, payload or override to pass here - that is deliberate, and it is what stops a write happening without the state check that planning did.
7
+
8
+ Before writing, JAM re-reads the issue and compares it to what the plan saw. If it moved, you get JAM_WRITE_CONFLICT and no write happens: call jira_write_plan again against the new state rather than treating the conflict as a transient failure.
9
+
10
+ After writing, JAM reads the issue back and checks the intended result is actually there. Only then does it return "applied". Jira accepting a request is not the same as the issue having changed.
11
+
12
+ Failures worth handling differently:
13
+ - JAM_WRITE_CONFLICT the issue moved; re-plan
14
+ - JAM_WRITE_PLAN_EXPIRED the plan aged out; re-plan
15
+ - JAM_WRITE_VERIFICATION_FAILED Jira accepted it but the issue does not show it; read the issue and tell the user
16
+ - JAM_WRITE_UNCERTAIN JAM does not know whether it landed; read the issue. Do NOT call this tool again - the write may already have been applied, and applying it twice is a second comment or a second transition.
17
+
18
+ Never report an uncertain or unverified write as done.`;
19
+ export function registerJiraWriteApply(server, deps) {
20
+ server.registerTool("jira_write_apply", {
21
+ title: "Apply a planned change to a Jira issue (writes)",
22
+ description: DESCRIPTION,
23
+ inputSchema: {
24
+ planId: z
25
+ .string()
26
+ .min(1)
27
+ .describe("The planId returned by jira_write_plan. Single use."),
28
+ },
29
+ // Mutating, but not destructive in the sense hosts warn about: every
30
+ // supported operation adds or changes a field, and none delete anything.
31
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
32
+ }, async (args) => runTool("jira_write_apply", deps.telemetry, () => applyWritePlan(deps, { planId: args.planId })));
33
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { JamDeps } from "../../deps.js";
3
+ export declare function registerJiraWritePlan(server: McpServer, deps: JamDeps): void;
@@ -0,0 +1,85 @@
1
+ import { z } from "zod";
2
+ import { planWrite } from "../../application/plan-write.js";
3
+ import { CREATABLE_FIELDS, WRITABLE_FIELDS, WRITE_OPERATIONS } from "../../domain/write.js";
4
+ import { runTool } from "../tool-result.js";
5
+ const DESCRIPTION = `Work out how to change one Jira issue, and get back a plan. Changes nothing.
6
+
7
+ This is the first half of every write. Call it, read what it says the issue looks like now and what it would become, then pass the returned planId to jira_write_apply. There is no way to write to Jira without a plan, and a plan cannot be assembled by hand - only jira_write_plan issues one.
8
+
9
+ Operations on an issue that already exists - these need \`key\`:
10
+ - comment.add input: { "text": "..." } plain text; JAM converts it, do not send ADF
11
+ - field.update input: { "summary"?, "priority"?, "labels"?, "components"? }
12
+ - status.transition input: { "status": "Done" } JAM asks Jira which transitions exist and matches yours
13
+
14
+ Creating an issue - no \`key\`, because there is no issue yet:
15
+ - issue.create input: { "issueType": "Task", "summary": "...", "description"?, "priority"?, "labels"?, "components"? }
16
+
17
+ issue.create goes into the project this workspace is bound to; the project is not a parameter. Planning reads Jira's create schema for that project first, so an issue type Jira does not offer, a priority or component outside its allowed values, and a project whose create screen requires a field JAM cannot set are all refused here rather than attempted. \`description\` is plain text, like a comment. Not settable in this version: assignee, reporter, parent, custom fields, attachments.
18
+
19
+ Writes are limited to the Jira project this workspace is bound to; a key from another project is refused rather than attempted.
20
+
21
+ The plan records what the issue looked like when it was made, and expires. If the issue changes in the meantime, jira_write_apply refuses with JAM_WRITE_CONFLICT - re-plan against the new state rather than forcing the old one through.
22
+
23
+ A plan is a statement about what is possible right now, not a promise that it will happen. Nothing is written until jira_write_apply runs.`;
24
+ export function registerJiraWritePlan(server, deps) {
25
+ server.registerTool("jira_write_plan", {
26
+ title: "Plan a change to a Jira issue (writes nothing)",
27
+ description: DESCRIPTION,
28
+ inputSchema: {
29
+ // Optional at the schema level because issue.create has no issue to
30
+ // name. Every other operation requires it, and planning refuses one
31
+ // that arrives without it - so the schema says "sometimes", and the
32
+ // server says which times.
33
+ key: z
34
+ .string()
35
+ .min(1)
36
+ .optional()
37
+ .describe('Issue key, e.g. "PROJECT-123". Required for comment.add, field.update and status.transition; omit for issue.create, which has no issue yet. Must be in the configured project.'),
38
+ operation: z
39
+ .enum(WRITE_OPERATIONS)
40
+ .describe(`What to do: ${WRITE_OPERATIONS.join(", ")}.`),
41
+ // Loose, not stripping. A strict object would refuse an unknown field
42
+ // with a schema error, and the default stripping one would silently
43
+ // drop it - which is worse: an agent that asked to set an assignee
44
+ // would get an issue without one and a receipt that never mentions it.
45
+ // Letting unknown keys through means JAM refuses them itself, by name,
46
+ // with the supported list attached.
47
+ input: z
48
+ .looseObject({
49
+ text: z.string().min(1).optional().describe("comment.add: the comment, as plain text."),
50
+ status: z
51
+ .string()
52
+ .min(1)
53
+ .optional()
54
+ .describe("status.transition: the status to move to, e.g. \"Done\"."),
55
+ issueType: z
56
+ .string()
57
+ .min(1)
58
+ .optional()
59
+ .describe('issue.create: the issue type by name, e.g. "Task". Matched against the types Jira offers for this project.'),
60
+ description: z
61
+ .string()
62
+ .optional()
63
+ .describe("issue.create: the description, as plain text. JAM converts it; do not send ADF."),
64
+ summary: z.string().min(1).optional(),
65
+ priority: z.string().min(1).optional().describe('Priority name, e.g. "High".'),
66
+ labels: z.array(z.string()).optional().describe("Replaces the whole label set."),
67
+ components: z
68
+ .array(z.string())
69
+ .optional()
70
+ .describe("Component names. Replaces the whole component set."),
71
+ })
72
+ .describe(`Operation input. field.update accepts only ${WRITABLE_FIELDS.join(", ")}; issue.create accepts only ${CREATABLE_FIELDS.join(", ")}. Custom fields and assignee are not writable by either.`),
73
+ },
74
+ // Planning reads Jira and decides; it never mutates. Hosts are free to
75
+ // run it without asking, which is what keeps the two-step shape cheap.
76
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
77
+ }, async (args) => runTool("jira_write_plan", deps.telemetry, async () => {
78
+ const { receipt } = await planWrite(deps, {
79
+ ...(args.key !== undefined ? { key: args.key } : {}),
80
+ operation: args.operation,
81
+ input: args.input,
82
+ });
83
+ return receipt;
84
+ }));
85
+ }
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * Read-after-write rule.
3
3
  *
4
- * JAM is read-only in this release, so nothing enforces this at runtime yet.
5
- * The rule is fixed here so the write adapter, when it lands, cannot quietly
6
- * confirm a write with a stale JQL search result.
7
- *
8
4
  * Normal read -> Enhanced JQL search (`jira_search`)
9
5
  * Post-write read -> direct issue GET for the affected key
6
+ *
7
+ * "Direct issue GET" means `JiraReadPort.getIssue` - one key, one
8
+ * `GET /rest/api/3/issue/{key}`. Not a search, whose index can lag behind the
9
+ * issue it describes, and not `getIssues`: that is a bulk endpoint taking a
10
+ * list, and a bulk read is free to answer from a different path than the
11
+ * single-issue one. The difference is invisible in a listing and decisive in
12
+ * the read that says whether a mutation may proceed, or whether one landed.
13
+ *
14
+ * Every read the write plane makes goes through it: the pre-write conflict
15
+ * check, the post-write confirmation, and the post-create confirmation.
10
16
  */
11
17
  export type ReadMode = "search" | "direct";
12
18
  export declare function readModeAfterWrite(): ReadMode;
@@ -0,0 +1,86 @@
1
+ import { type CreateFieldMetadata, type CreateIssueInput, type CreateIssueType, type CreateSchemaRequirements } from "../domain/write.js";
2
+ /**
3
+ * What JAM will agree to create, decided from what Jira says it accepts.
4
+ *
5
+ * Creation is the one write with no issue to look at first, so every check
6
+ * here is against the project's create schema instead. The rule throughout is
7
+ * the one the rest of the write plane follows: resolve against what Jira just
8
+ * reported, never against what the caller asserted or what a name suggests. An
9
+ * issue type id is not derived from a type name, a priority is not sent
10
+ * because it looked plausible, and a required field JAM cannot express is
11
+ * refused here rather than posted and rejected as a 400.
12
+ */
13
+ /**
14
+ * Jira field ids for the fields JAM can put on a create.
15
+ *
16
+ * The bridge between the public contract (CREATABLE_FIELDS, which an agent
17
+ * sees) and Jira's own ids (which the required-field gate compares against).
18
+ * Both directions matter: one decides what may be asked for, the other decides
19
+ * what counts as "JAM supplies this".
20
+ */
21
+ export declare const CREATE_FIELD_IDS: {
22
+ readonly issueType: "issuetype";
23
+ readonly summary: "summary";
24
+ readonly description: "description";
25
+ readonly priority: "priority";
26
+ readonly labels: "labels";
27
+ readonly components: "components";
28
+ };
29
+ /**
30
+ * Match a requested issue type against the ones Jira offers for this project.
31
+ *
32
+ * Case-insensitive, because "task" and "Task" are the same intent and an agent
33
+ * has no way to learn Jira's casing before asking. Nothing else is inferred:
34
+ * the id comes from Jira's own list, and a type that is not on it is refused
35
+ * with the list attached, so the next move is to pick one rather than to
36
+ * rephrase the same one.
37
+ *
38
+ * Subtask types are refused separately. They need a parent, which is not in
39
+ * this version's contract, so "not available" would be the wrong answer - the
40
+ * type exists, and JAM cannot use it yet.
41
+ */
42
+ export declare function resolveIssueType(requested: string, available: CreateIssueType[]): CreateIssueType;
43
+ /**
44
+ * Refuse a create whose project requires something JAM cannot put on it.
45
+ *
46
+ * The alternative - post it and let Jira answer 400 - is worse twice over: the
47
+ * agent gets a vendor error instead of a JAM decision, and creation is the one
48
+ * write where "did it happen?" is expensive to answer after the fact. So the
49
+ * answer is worked out before anything is sent.
50
+ *
51
+ * A field Jira says it will default is not JAM's to supply. That is Jira
52
+ * stating a fact about its own configuration, not JAM guessing one.
53
+ *
54
+ * Returns the required field ids, which the plan records so apply can tell a
55
+ * newly-required field from one that was always there.
56
+ */
57
+ export declare function assertRequiredFieldsSupported(fields: CreateFieldMetadata[], input: CreateIssueInput): string[];
58
+ /**
59
+ * Turn a requested value into one Jira currently offers for that field.
60
+ *
61
+ * An unconstrained field passes the value through: there is no list to check
62
+ * it against, and inventing one would refuse valid input. Absent and empty are
63
+ * different - absent means Jira did not constrain the field, empty means it
64
+ * constrains it and offers nothing.
65
+ *
66
+ * The shape is resolveTransition's, deliberately: human intent, then
67
+ * Jira-provided candidates, then a concrete Jira value. Nothing in between
68
+ * guesses.
69
+ */
70
+ export declare function resolveAllowedValue(field: CreateFieldMetadata | undefined, requested: string, label: string): {
71
+ requested: string;
72
+ resolved: string;
73
+ };
74
+ /**
75
+ * Are this plan's premises still true?
76
+ *
77
+ * Semantic, not a document comparison. Comparing a hash of the metadata would
78
+ * make an unrelated optional field appearing on the create screen invalidate
79
+ * every outstanding plan - which is wrong, and on an active project constant.
80
+ * What matters is narrower: the issue type still exists, no new required field
81
+ * has appeared that JAM cannot fill, and every value resolved from an allowed
82
+ * list is still on it.
83
+ *
84
+ * Everything else about the schema may change freely between plan and apply.
85
+ */
86
+ export declare function assertSchemaUnchanged(requirements: CreateSchemaRequirements, issueTypes: CreateIssueType[], fields: CreateFieldMetadata[], input: CreateIssueInput): void;