@jam-mcp/server 1.1.0 → 1.3.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 (48) hide show
  1. package/README.md +2 -2
  2. package/dist/adapters/credentials/windows-user-env.d.ts +3 -1
  3. package/dist/adapters/credentials/windows-user-env.js +20 -1
  4. package/dist/adapters/jira-cloud/jira-assignee-resolution.adapter.d.ts +39 -0
  5. package/dist/adapters/jira-cloud/jira-assignee-resolution.adapter.js +94 -0
  6. package/dist/adapters/jira-cloud/jira-create-metadata.adapter.d.ts +27 -0
  7. package/dist/adapters/jira-cloud/jira-create-metadata.adapter.js +81 -0
  8. package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +10 -1
  9. package/dist/adapters/jira-cloud/jira-read.adapter.js +26 -0
  10. package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +22 -7
  11. package/dist/adapters/jira-cloud/jira-write.adapter.js +41 -20
  12. package/dist/application/apply-create-issue.d.ts +20 -0
  13. package/dist/application/apply-create-issue.js +187 -0
  14. package/dist/application/apply-write.js +56 -5
  15. package/dist/application/plan-create-issue.d.ts +44 -0
  16. package/dist/application/plan-create-issue.js +188 -0
  17. package/dist/application/plan-write.d.ts +16 -4
  18. package/dist/application/plan-write.js +107 -18
  19. package/dist/application/write-plan-store.d.ts +2 -2
  20. package/dist/application/write-plan-store.js +13 -1
  21. package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
  22. package/dist/cli/auth.d.ts +6 -0
  23. package/dist/cli/auth.js +2 -1
  24. package/dist/deps.d.ts +20 -0
  25. package/dist/deps.js +12 -0
  26. package/dist/domain/adf.d.ts +35 -0
  27. package/dist/domain/adf.js +65 -0
  28. package/dist/domain/errors.d.ts +1 -1
  29. package/dist/domain/errors.js +17 -0
  30. package/dist/domain/write.d.ts +165 -15
  31. package/dist/domain/write.js +34 -1
  32. package/dist/mcp/create-server.d.ts +4 -0
  33. package/dist/mcp/create-server.js +5 -0
  34. package/dist/mcp/tools/jira-write-plan.tool.js +42 -6
  35. package/dist/policy/assignee-policy.d.ts +60 -0
  36. package/dist/policy/assignee-policy.js +103 -0
  37. package/dist/policy/consistency-policy.d.ts +10 -4
  38. package/dist/policy/create-policy.d.ts +86 -0
  39. package/dist/policy/create-policy.js +182 -0
  40. package/dist/policy/write-policy.d.ts +10 -1
  41. package/dist/policy/write-policy.js +15 -1
  42. package/dist/ports/jira-assignee-resolution.port.d.ts +51 -0
  43. package/dist/ports/jira-assignee-resolution.port.js +1 -0
  44. package/dist/ports/jira-create-metadata.port.d.ts +25 -0
  45. package/dist/ports/jira-create-metadata.port.js +1 -0
  46. package/dist/ports/jira-read.port.d.ts +34 -0
  47. package/dist/ports/jira-write.port.d.ts +17 -0
  48. package/package.json +2 -2
@@ -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;
@@ -0,0 +1,182 @@
1
+ import { JamError } from "../domain/errors.js";
2
+ import { CREATABLE_FIELDS, } from "../domain/write.js";
3
+ /**
4
+ * What JAM will agree to create, decided from what Jira says it accepts.
5
+ *
6
+ * Creation is the one write with no issue to look at first, so every check
7
+ * here is against the project's create schema instead. The rule throughout is
8
+ * the one the rest of the write plane follows: resolve against what Jira just
9
+ * reported, never against what the caller asserted or what a name suggests. An
10
+ * issue type id is not derived from a type name, a priority is not sent
11
+ * because it looked plausible, and a required field JAM cannot express is
12
+ * refused here rather than posted and rejected as a 400.
13
+ */
14
+ /**
15
+ * Jira field ids for the fields JAM can put on a create.
16
+ *
17
+ * The bridge between the public contract (CREATABLE_FIELDS, which an agent
18
+ * sees) and Jira's own ids (which the required-field gate compares against).
19
+ * Both directions matter: one decides what may be asked for, the other decides
20
+ * what counts as "JAM supplies this".
21
+ */
22
+ export const CREATE_FIELD_IDS = {
23
+ issueType: "issuetype",
24
+ summary: "summary",
25
+ description: "description",
26
+ priority: "priority",
27
+ labels: "labels",
28
+ components: "components",
29
+ };
30
+ /** Always sent by JAM, so a project requiring them is still servable. */
31
+ const ALWAYS_SUPPLIED = [CREATE_FIELD_IDS.issueType, CREATE_FIELD_IDS.summary, "project"];
32
+ /**
33
+ * Fields JAM can supply when asked, and so can satisfy a required flag - but
34
+ * only if the caller actually asked for them. Required-and-absent is refused.
35
+ */
36
+ const SUPPLIABLE_ON_REQUEST = {
37
+ [CREATE_FIELD_IDS.description]: "description",
38
+ [CREATE_FIELD_IDS.priority]: "priority",
39
+ [CREATE_FIELD_IDS.labels]: "labels",
40
+ [CREATE_FIELD_IDS.components]: "components",
41
+ };
42
+ /**
43
+ * Match a requested issue type against the ones Jira offers for this project.
44
+ *
45
+ * Case-insensitive, because "task" and "Task" are the same intent and an agent
46
+ * has no way to learn Jira's casing before asking. Nothing else is inferred:
47
+ * the id comes from Jira's own list, and a type that is not on it is refused
48
+ * with the list attached, so the next move is to pick one rather than to
49
+ * rephrase the same one.
50
+ *
51
+ * Subtask types are refused separately. They need a parent, which is not in
52
+ * this version's contract, so "not available" would be the wrong answer - the
53
+ * type exists, and JAM cannot use it yet.
54
+ */
55
+ export function resolveIssueType(requested, available) {
56
+ const wanted = requested.trim().toLowerCase();
57
+ const match = available.find((t) => t.name.toLowerCase() === wanted);
58
+ if (!match) {
59
+ const creatable = available.filter((t) => !t.subtask).map((t) => t.name);
60
+ throw new JamError("JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE", creatable.length === 0
61
+ ? `Jira offers no issue types this account can create in this project, so "${requested}" cannot be created.`
62
+ : `"${requested}" is not an issue type this account can create in this project. Available: ${creatable.join(", ")}.`, {
63
+ requested,
64
+ available: available.map((t) => ({ id: t.id, name: t.name, subtask: t.subtask })),
65
+ });
66
+ }
67
+ if (match.subtask) {
68
+ throw new JamError("JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE", `"${match.name}" is a subtask type, which needs a parent issue. JAM does not set a parent, so it cannot create one.`, { requested, issueType: match.name, reason: "SUBTASK_UNSUPPORTED" });
69
+ }
70
+ return match;
71
+ }
72
+ /**
73
+ * Refuse a create whose project requires something JAM cannot put on it.
74
+ *
75
+ * The alternative - post it and let Jira answer 400 - is worse twice over: the
76
+ * agent gets a vendor error instead of a JAM decision, and creation is the one
77
+ * write where "did it happen?" is expensive to answer after the fact. So the
78
+ * answer is worked out before anything is sent.
79
+ *
80
+ * A field Jira says it will default is not JAM's to supply. That is Jira
81
+ * stating a fact about its own configuration, not JAM guessing one.
82
+ *
83
+ * Returns the required field ids, which the plan records so apply can tell a
84
+ * newly-required field from one that was always there.
85
+ */
86
+ export function assertRequiredFieldsSupported(fields, input) {
87
+ const required = fields.filter((f) => f.required);
88
+ const unsupported = [];
89
+ for (const field of required) {
90
+ if (ALWAYS_SUPPLIED.includes(field.id))
91
+ continue;
92
+ if (field.hasDefaultValue)
93
+ continue;
94
+ const inputKey = SUPPLIABLE_ON_REQUEST[field.id];
95
+ if (!inputKey) {
96
+ unsupported.push({ id: field.id, name: field.name, reason: "NOT_IN_JAM_CREATE_CONTRACT" });
97
+ continue;
98
+ }
99
+ if (input[inputKey] === undefined) {
100
+ unsupported.push({ id: field.id, name: field.name, reason: "REQUIRED_BUT_NOT_PROVIDED" });
101
+ }
102
+ }
103
+ if (unsupported.length > 0) {
104
+ const named = unsupported.map((f) => `${f.name} (${f.id})`).join(", ");
105
+ const it = unsupported.length === 1 ? "it" : "them";
106
+ throw new JamError("JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED", `This project requires ${named} when creating this issue type, and JAM cannot supply ${it}. Create this issue in Jira instead - JAM will not send a create it already knows Jira will reject.`, { unsupported, supported: [...CREATABLE_FIELDS] });
107
+ }
108
+ return required.map((f) => f.id);
109
+ }
110
+ /**
111
+ * Turn a requested value into one Jira currently offers for that field.
112
+ *
113
+ * An unconstrained field passes the value through: there is no list to check
114
+ * it against, and inventing one would refuse valid input. Absent and empty are
115
+ * different - absent means Jira did not constrain the field, empty means it
116
+ * constrains it and offers nothing.
117
+ *
118
+ * The shape is resolveTransition's, deliberately: human intent, then
119
+ * Jira-provided candidates, then a concrete Jira value. Nothing in between
120
+ * guesses.
121
+ */
122
+ export function resolveAllowedValue(field, requested, label) {
123
+ if (!field?.allowedValues)
124
+ return { requested, resolved: requested };
125
+ const wanted = requested.trim().toLowerCase();
126
+ const match = field.allowedValues.find((v) => v.name?.toLowerCase() === wanted);
127
+ if (!match?.name) {
128
+ const allowed = field.allowedValues.map((v) => v.name).filter(Boolean);
129
+ throw new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", allowed.length === 0
130
+ ? `Jira offers no ${label} values for this project and issue type, so "${requested}" cannot be set.`
131
+ : `"${requested}" is not an allowed ${label} for this project and issue type. Allowed: ${allowed.join(", ")}.`, { field: field.id, requested, allowed });
132
+ }
133
+ return { requested, resolved: match.name };
134
+ }
135
+ /**
136
+ * Are this plan's premises still true?
137
+ *
138
+ * Semantic, not a document comparison. Comparing a hash of the metadata would
139
+ * make an unrelated optional field appearing on the create screen invalidate
140
+ * every outstanding plan - which is wrong, and on an active project constant.
141
+ * What matters is narrower: the issue type still exists, no new required field
142
+ * has appeared that JAM cannot fill, and every value resolved from an allowed
143
+ * list is still on it.
144
+ *
145
+ * Everything else about the schema may change freely between plan and apply.
146
+ */
147
+ export function assertSchemaUnchanged(requirements, issueTypes, fields, input) {
148
+ const stillOffered = issueTypes.find((t) => t.id === requirements.issueTypeId);
149
+ if (!stillOffered) {
150
+ throw schemaChanged(`Issue type ${requirements.issueTypeName} is no longer available to this account in this project.`, { issueTypeId: requirements.issueTypeId, issueType: requirements.issueTypeName });
151
+ }
152
+ // A required field JAM cannot fill is a refusal whether it was there at plan
153
+ // time or arrived since - but arriving since is a changed schema rather than
154
+ // a bad request, so it is reported as one.
155
+ try {
156
+ assertRequiredFieldsSupported(fields, input);
157
+ }
158
+ catch (err) {
159
+ if (err instanceof JamError && err.code === "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED") {
160
+ throw schemaChanged("The create screen for this issue type now requires a field JAM cannot supply.", { cause: err.details });
161
+ }
162
+ throw err;
163
+ }
164
+ const byId = new Map(fields.map((f) => [f.id, f]));
165
+ for (const resolved of requirements.resolvedValues) {
166
+ const allowed = byId.get(resolved.fieldId)?.allowedValues;
167
+ // A field that stopped being constrained is not a problem: the value JAM
168
+ // resolved is still a value, and Jira is no longer restricting it.
169
+ if (!allowed)
170
+ continue;
171
+ if (!allowed.some((v) => v.name === resolved.resolved)) {
172
+ throw schemaChanged(`"${resolved.resolved}" is no longer an allowed value for ${resolved.fieldId} in this project.`, {
173
+ field: resolved.fieldId,
174
+ planned: resolved.resolved,
175
+ allowed: allowed.map((v) => v.name).filter(Boolean),
176
+ });
177
+ }
178
+ }
179
+ }
180
+ function schemaChanged(what, details) {
181
+ return new JamError("JAM_WRITE_SCHEMA_CHANGED", `${what} This plan was built on the create schema as it was, so it no longer describes a create JAM can make. Nothing was created - plan again against the current schema.`, details);
182
+ }
@@ -1,4 +1,4 @@
1
- import { type FieldUpdateInput, type JiraTransition, type WriteOperation } from "../domain/write.js";
1
+ import { type ExistingIssueOperation, type FieldUpdateInput, type JiraTransition, type WriteOperation } from "../domain/write.js";
2
2
  /**
3
3
  * What a write is allowed to be, decided before anything reaches Jira.
4
4
  *
@@ -32,6 +32,15 @@ export declare function projectKeyOf(issueKey: string): string | undefined;
32
32
  */
33
33
  export declare function assertWriteScope(issueKey: string, configuredProject: string): string;
34
34
  export declare function assertOperationAllowed(operation: string): WriteOperation;
35
+ /**
36
+ * Narrow an already-allowed operation to one that acts on an existing issue.
37
+ *
38
+ * Reached only after routing has sent `issue.create` elsewhere, so this is a
39
+ * type-level guarantee rather than a second policy decision - but it is a
40
+ * guarantee worth having, because everything downstream reads an issue key
41
+ * that creation does not have.
42
+ */
43
+ export declare function assertExistingIssueOperation(operation: string): ExistingIssueOperation;
35
44
  /**
36
45
  * Reject anything outside the field whitelist before planning goes further.
37
46
  *
@@ -1,5 +1,5 @@
1
1
  import { JamError } from "../domain/errors.js";
2
- import { isWritableField, isWriteOperation, WRITABLE_FIELDS, WRITE_OPERATIONS, } from "../domain/write.js";
2
+ import { EXISTING_ISSUE_OPERATIONS, isExistingIssueOperation, isWritableField, isWriteOperation, WRITABLE_FIELDS, WRITE_OPERATIONS, } from "../domain/write.js";
3
3
  /**
4
4
  * What a write is allowed to be, decided before anything reaches Jira.
5
5
  *
@@ -54,6 +54,20 @@ export function assertOperationAllowed(operation) {
54
54
  }
55
55
  return operation;
56
56
  }
57
+ /**
58
+ * Narrow an already-allowed operation to one that acts on an existing issue.
59
+ *
60
+ * Reached only after routing has sent `issue.create` elsewhere, so this is a
61
+ * type-level guarantee rather than a second policy decision - but it is a
62
+ * guarantee worth having, because everything downstream reads an issue key
63
+ * that creation does not have.
64
+ */
65
+ export function assertExistingIssueOperation(operation) {
66
+ if (!isExistingIssueOperation(operation)) {
67
+ throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", `"${operation}" does not change an existing issue. Operations that do: ${EXISTING_ISSUE_OPERATIONS.join(", ")}.`, { operation, supported: [...EXISTING_ISSUE_OPERATIONS] });
68
+ }
69
+ return operation;
70
+ }
57
71
  /**
58
72
  * Reject anything outside the field whitelist before planning goes further.
59
73
  *
@@ -0,0 +1,51 @@
1
+ import type { AssigneeCandidate } from "../domain/write.js";
2
+ /**
3
+ * Who Jira thinks a name refers to, and whether they can hold this issue.
4
+ *
5
+ * A third read-shaped port, for the same reason `JiraCreateMetadataPort` is
6
+ * one: nothing here mutates, so it does not belong behind the write port's
7
+ * no-retry contract, and it answers a question about a directory rather than
8
+ * about an issue, so the read port's completeness semantics would mean nothing
9
+ * for it.
10
+ *
11
+ * The two calls are deliberately separate questions. Searching answers "who
12
+ * did the caller mean", and it is allowed to be fuzzy - Jira matches on
13
+ * substrings, and a partial match is a suggestion to show a human. Checking
14
+ * assignability answers "may this exact person hold this exact issue", and it
15
+ * is not fuzzy at all: it takes an accountId that resolution has already
16
+ * settled on. Collapsing them would let a substring match decide a mutation.
17
+ *
18
+ * Neither call retries. Their answers decide a mutation, and a retried answer
19
+ * is a possibly-stale one - the same argument that keeps `getTransitions` and
20
+ * the create metadata calls on the non-retrying side.
21
+ */
22
+ export interface JiraAssigneeResolutionPort {
23
+ /**
24
+ * Users matching a query, as Jira's own directory reports them.
25
+ *
26
+ * Fuzzy by nature. What comes back is candidates, never a decision - see
27
+ * `resolveAssignee` for what JAM will and will not do with them.
28
+ */
29
+ searchUsers(query: string): Promise<AssigneeCandidate[]>;
30
+ /**
31
+ * One user, looked up by identity rather than found by searching.
32
+ *
33
+ * JAM's contract says `assignee` may be an accountId, and a contract about
34
+ * identity has to be met by an identity lookup. Jira's user search does
35
+ * currently return a user when the query happens to be their accountId, but
36
+ * that is a property of a substring search, not a promise - relying on it
37
+ * means the accountId half of the contract holds by coincidence.
38
+ *
39
+ * Absent means Jira has no such account, or this token cannot see it. Both
40
+ * are "you cannot assign this", which is the caller's answer either way.
41
+ */
42
+ getUserByAccountId(accountId: string): Promise<AssigneeCandidate | undefined>;
43
+ /**
44
+ * Whether this exact account may be assigned this exact issue, right now.
45
+ *
46
+ * Asked by accountId, so it is an identity question rather than a name one.
47
+ * Asked again immediately before the write, because a permission that held
48
+ * when the plan was made is not the same as one that still holds.
49
+ */
50
+ isAssignable(issueKey: string, accountId: string): Promise<boolean>;
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ import type { CreateFieldMetadata, CreateIssueType } from "../domain/write.js";
2
+ /**
3
+ * What Jira will accept when creating an issue in one project, right now.
4
+ *
5
+ * Separate from `JiraWritePort` on purpose. Everything behind that port
6
+ * changes something; nothing behind this one does. Folding schema discovery
7
+ * into the write port would put a read on the far side of a boundary whose
8
+ * whole documented rule is "these calls mutate, and none of them retry" - and
9
+ * the rule is worth more than the one fewer interface.
10
+ *
11
+ * Kept out of `JiraReadPort` too, for the opposite reason: that port answers
12
+ * questions about issues, with completeness semantics attached to every
13
+ * result. This answers a question about a project's configuration, and
14
+ * `meta.complete` would mean nothing here.
15
+ *
16
+ * These calls do not retry. Their answers decide a mutation, and a retried
17
+ * answer is a possibly-stale one - the same argument that keeps
18
+ * `getTransitions` on the non-retrying side.
19
+ */
20
+ export interface JiraCreateMetadataPort {
21
+ /** Issue types this account may create in this project. */
22
+ getIssueTypes(projectKey: string): Promise<CreateIssueType[]>;
23
+ /** The create screen's fields for one issue type in one project. */
24
+ getCreateFields(projectKey: string, issueTypeId: string): Promise<CreateFieldMetadata[]>;
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -20,6 +20,26 @@ export type GetIssuesRequest = {
20
20
  keys: string[];
21
21
  fields: string[];
22
22
  };
23
+ export type GetIssueRequest = {
24
+ key: string;
25
+ fields: string[];
26
+ };
27
+ export type GetIssueResult = {
28
+ /** Absent when Jira has no such issue, or this account cannot see it. */
29
+ issue?: FullIssueContext;
30
+ /**
31
+ * Who the issue is assigned to, by identity rather than by name.
32
+ *
33
+ * Here rather than on the issue because only the write plane needs it. Two
34
+ * people can share a display name, so `assignee` cannot settle whether an
35
+ * assignment landed on the right person - and the read tools have no use for
36
+ * an accountId, so their payload does not grow one.
37
+ *
38
+ * Absent when the issue is unassigned, or when the field was not requested.
39
+ */
40
+ assigneeAccountId?: string;
41
+ responseBytes: number;
42
+ };
23
43
  export type GetIssuesResult = {
24
44
  issues: FullIssueContext[];
25
45
  /** Keys that were requested but not returned (missing or not permitted). */
@@ -59,6 +79,20 @@ export type ListProjectsResult = {
59
79
  };
60
80
  export interface JiraReadPort {
61
81
  searchPage(req: SearchPageRequest): Promise<SearchPageResult>;
82
+ /**
83
+ * One issue, read directly by key.
84
+ *
85
+ * Separate from `getIssues` because ConsistencyPolicy requires a direct
86
+ * issue GET for anything that decides or confirms a write, and `getIssues`
87
+ * is not one: it is a bulk endpoint that takes a list, and a bulk read is
88
+ * free to answer from a different path than a single-issue GET. The
89
+ * distinction only matters in one place - the write plane - which is exactly
90
+ * where being wrong about it is most expensive.
91
+ *
92
+ * Reads and writes both use it: the pre-write conflict check, the post-write
93
+ * confirmation, and the post-create confirmation.
94
+ */
95
+ getIssue(req: GetIssueRequest): Promise<GetIssueResult>;
62
96
  getIssues(req: GetIssuesRequest): Promise<GetIssuesResult>;
63
97
  getComments(req: GetCommentsRequest): Promise<GetCommentsResult>;
64
98
  /** Used by `jam doctor` to prove authentication works. */
@@ -16,6 +16,15 @@ import type { JiraTransition } from "../domain/write.js";
16
16
  * surface is not: only whitelisted operations reach it (see domain/write.ts).
17
17
  */
18
18
  export interface JiraWritePort {
19
+ /**
20
+ * Create one issue. The only call here that brings an issue into existence,
21
+ * and the one where a retry is most expensive: a duplicate update is a
22
+ * no-op, a duplicate create is a second issue on someone's board.
23
+ */
24
+ createIssue(fields: Record<string, unknown>): Promise<{
25
+ id: string;
26
+ key: string;
27
+ }>;
19
28
  updateIssue(key: string, fields: Record<string, unknown>): Promise<void>;
20
29
  addComment(key: string, body: string): Promise<{
21
30
  id: string;
@@ -23,4 +32,12 @@ export interface JiraWritePort {
23
32
  /** Transitions Jira offers for this issue right now, for this account. */
24
33
  getTransitions(key: string): Promise<JiraTransition[]>;
25
34
  transitionIssue(key: string, transitionId: string): Promise<void>;
35
+ /**
36
+ * Assign an issue to one account.
37
+ *
38
+ * By accountId, never by name: Jira Cloud identifies users by account, and a
39
+ * display name is a label two people can share. Which account it is was
40
+ * settled during planning.
41
+ */
42
+ assignIssue(key: string, accountId: string): Promise<void>;
26
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jam-mcp/server",
3
- "version": "1.1.0",
3
+ "version": "1.3.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.1.0",
44
+ "@jam-mcp/launcher": "1.3.0",
45
45
  "@modelcontextprotocol/sdk": "^1.30.0",
46
46
  "yaml": "^2.9.0",
47
47
  "zod": "^4.4.3"