@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.
- package/README.md +2 -2
- package/dist/adapters/credentials/windows-user-env.d.ts +3 -1
- package/dist/adapters/credentials/windows-user-env.js +20 -1
- package/dist/adapters/jira-cloud/jira-assignee-resolution.adapter.d.ts +39 -0
- package/dist/adapters/jira-cloud/jira-assignee-resolution.adapter.js +94 -0
- package/dist/adapters/jira-cloud/jira-create-metadata.adapter.d.ts +27 -0
- package/dist/adapters/jira-cloud/jira-create-metadata.adapter.js +81 -0
- package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +10 -1
- package/dist/adapters/jira-cloud/jira-read.adapter.js +26 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +22 -7
- package/dist/adapters/jira-cloud/jira-write.adapter.js +41 -20
- package/dist/application/apply-create-issue.d.ts +20 -0
- package/dist/application/apply-create-issue.js +187 -0
- package/dist/application/apply-write.js +56 -5
- package/dist/application/plan-create-issue.d.ts +44 -0
- package/dist/application/plan-create-issue.js +188 -0
- package/dist/application/plan-write.d.ts +16 -4
- package/dist/application/plan-write.js +107 -18
- package/dist/application/write-plan-store.d.ts +2 -2
- package/dist/application/write-plan-store.js +13 -1
- package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
- package/dist/cli/auth.d.ts +6 -0
- package/dist/cli/auth.js +2 -1
- package/dist/deps.d.ts +20 -0
- package/dist/deps.js +12 -0
- package/dist/domain/adf.d.ts +35 -0
- package/dist/domain/adf.js +65 -0
- package/dist/domain/errors.d.ts +1 -1
- package/dist/domain/errors.js +17 -0
- package/dist/domain/write.d.ts +165 -15
- package/dist/domain/write.js +34 -1
- package/dist/mcp/create-server.d.ts +4 -0
- package/dist/mcp/create-server.js +5 -0
- package/dist/mcp/tools/jira-write-plan.tool.js +42 -6
- package/dist/policy/assignee-policy.d.ts +60 -0
- package/dist/policy/assignee-policy.js +103 -0
- package/dist/policy/consistency-policy.d.ts +10 -4
- package/dist/policy/create-policy.d.ts +86 -0
- package/dist/policy/create-policy.js +182 -0
- package/dist/policy/write-policy.d.ts +10 -1
- package/dist/policy/write-policy.js +15 -1
- package/dist/ports/jira-assignee-resolution.port.d.ts +51 -0
- package/dist/ports/jira-assignee-resolution.port.js +1 -0
- package/dist/ports/jira-create-metadata.port.d.ts +25 -0
- package/dist/ports/jira-create-metadata.port.js +1 -0
- package/dist/ports/jira-read.port.d.ts +34 -0
- package/dist/ports/jira-write.port.d.ts +17 -0
- 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.
|
|
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.
|
|
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"
|