@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.
- package/LICENSE +21 -21
- package/README.md +86 -72
- 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-client.d.ts +10 -1
- package/dist/adapters/jira-cloud/jira-client.js +1 -1
- 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 +17 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +41 -6
- package/dist/adapters/jira-cloud/jira-write.adapter.js +96 -11
- package/dist/application/apply-create-issue.d.ts +20 -0
- package/dist/application/apply-create-issue.js +187 -0
- package/dist/application/apply-write.d.ts +25 -0
- package/dist/application/apply-write.js +166 -0
- 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 +39 -0
- package/dist/application/plan-write.js +210 -0
- package/dist/application/write-plan-store.d.ts +42 -0
- package/dist/application/write-plan-store.js +81 -0
- package/dist/bootstrap/boot-health-gate.js +2 -2
- 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/cli-entry.js +33 -33
- package/dist/deps.d.ts +26 -0
- package/dist/deps.js +14 -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 +22 -0
- package/dist/domain/write.d.ts +230 -0
- package/dist/domain/write.js +65 -0
- package/dist/mcp/create-server.d.ts +12 -3
- package/dist/mcp/create-server.js +35 -6
- package/dist/mcp/tools/jira-write-apply.tool.d.ts +3 -0
- package/dist/mcp/tools/jira-write-apply.tool.js +33 -0
- package/dist/mcp/tools/jira-write-plan.tool.d.ts +3 -0
- package/dist/mcp/tools/jira-write-plan.tool.js +85 -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 +69 -0
- package/dist/policy/write-policy.js +128 -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 +23 -0
- package/dist/ports/jira-write.port.d.ts +27 -4
- package/package.json +69 -69
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type ExistingIssueOperation, type FieldUpdateInput, type JiraTransition, type WriteOperation } from "../domain/write.js";
|
|
2
|
+
/**
|
|
3
|
+
* What a write is allowed to be, decided before anything reaches Jira.
|
|
4
|
+
*
|
|
5
|
+
* These checks exist so a refusal is a JAM answer rather than a Jira 403 or a
|
|
6
|
+
* 404 with no explanation. "You asked to change an issue in another project"
|
|
7
|
+
* and "Jira says that issue does not exist" look identical from the REST layer
|
|
8
|
+
* and mean entirely different things to whoever has to act next.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* How long a plan stays applicable.
|
|
12
|
+
*
|
|
13
|
+
* Short on purpose. A plan carries a snapshot of the issue, and the older that
|
|
14
|
+
* snapshot is the more likely apply is about to reject it anyway - so the
|
|
15
|
+
* window is measured in the time an agent needs to decide, not in how long a
|
|
16
|
+
* person might leave a terminal open.
|
|
17
|
+
*/
|
|
18
|
+
export declare const PLAN_TTL_MS: number;
|
|
19
|
+
/**
|
|
20
|
+
* Which project an issue key belongs to.
|
|
21
|
+
*
|
|
22
|
+
* Parsed rather than looked up: a malformed key should be refused before it is
|
|
23
|
+
* spent on a Jira round trip.
|
|
24
|
+
*/
|
|
25
|
+
export declare function projectKeyOf(issueKey: string): string | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Writes stay inside the project this workspace is bound to.
|
|
28
|
+
*
|
|
29
|
+
* The binding is what the user consented to when they set JAM up. An agent
|
|
30
|
+
* holding a key from somewhere else - copied from a link, inferred from a
|
|
31
|
+
* comment - must not be able to reach into another team's project through it.
|
|
32
|
+
*/
|
|
33
|
+
export declare function assertWriteScope(issueKey: string, configuredProject: string): string;
|
|
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;
|
|
44
|
+
/**
|
|
45
|
+
* Reject anything outside the field whitelist before planning goes further.
|
|
46
|
+
*
|
|
47
|
+
* Returns the requested fields in whitelist order so a plan's `before` and
|
|
48
|
+
* `intendedAfter` are comparable regardless of how the caller ordered them.
|
|
49
|
+
*/
|
|
50
|
+
export declare function assertFieldsAllowed(input: FieldUpdateInput): FieldUpdateInput;
|
|
51
|
+
/**
|
|
52
|
+
* Match a requested status against what Jira currently offers.
|
|
53
|
+
*
|
|
54
|
+
* Jira's transition ids are per-workflow and not derivable from a status name,
|
|
55
|
+
* so this only ever selects from transitions Jira just reported. When nothing
|
|
56
|
+
* matches, the available names go back with the error - the agent's next move
|
|
57
|
+
* is to pick one of them, not to try harder at the same one.
|
|
58
|
+
*/
|
|
59
|
+
export declare function resolveTransition(target: string, available: JiraTransition[]): JiraTransition;
|
|
60
|
+
export declare function planExpired(expiresAt: string, now: Date): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Has the issue moved since the plan was made?
|
|
63
|
+
*
|
|
64
|
+
* Jira's `updated` timestamp is the revision marker: it changes on any edit,
|
|
65
|
+
* including ones JAM did not make and cannot see the shape of. Comparing it is
|
|
66
|
+
* what stops an agent applying a decision it made against an issue that no
|
|
67
|
+
* longer exists in that form.
|
|
68
|
+
*/
|
|
69
|
+
export declare function assertUnchanged(issueKey: string, baseUpdated: string, currentUpdated: string): void;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { JamError } from "../domain/errors.js";
|
|
2
|
+
import { EXISTING_ISSUE_OPERATIONS, isExistingIssueOperation, isWritableField, isWriteOperation, WRITABLE_FIELDS, WRITE_OPERATIONS, } from "../domain/write.js";
|
|
3
|
+
/**
|
|
4
|
+
* What a write is allowed to be, decided before anything reaches Jira.
|
|
5
|
+
*
|
|
6
|
+
* These checks exist so a refusal is a JAM answer rather than a Jira 403 or a
|
|
7
|
+
* 404 with no explanation. "You asked to change an issue in another project"
|
|
8
|
+
* and "Jira says that issue does not exist" look identical from the REST layer
|
|
9
|
+
* and mean entirely different things to whoever has to act next.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* How long a plan stays applicable.
|
|
13
|
+
*
|
|
14
|
+
* Short on purpose. A plan carries a snapshot of the issue, and the older that
|
|
15
|
+
* snapshot is the more likely apply is about to reject it anyway - so the
|
|
16
|
+
* window is measured in the time an agent needs to decide, not in how long a
|
|
17
|
+
* person might leave a terminal open.
|
|
18
|
+
*/
|
|
19
|
+
export const PLAN_TTL_MS = 10 * 60 * 1000;
|
|
20
|
+
const KEY_PATTERN = /^([A-Z][A-Z0-9_]*)-(\d+)$/;
|
|
21
|
+
/**
|
|
22
|
+
* Which project an issue key belongs to.
|
|
23
|
+
*
|
|
24
|
+
* Parsed rather than looked up: a malformed key should be refused before it is
|
|
25
|
+
* spent on a Jira round trip.
|
|
26
|
+
*/
|
|
27
|
+
export function projectKeyOf(issueKey) {
|
|
28
|
+
return KEY_PATTERN.exec(issueKey.trim().toUpperCase())?.[1];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Writes stay inside the project this workspace is bound to.
|
|
32
|
+
*
|
|
33
|
+
* The binding is what the user consented to when they set JAM up. An agent
|
|
34
|
+
* holding a key from somewhere else - copied from a link, inferred from a
|
|
35
|
+
* comment - must not be able to reach into another team's project through it.
|
|
36
|
+
*/
|
|
37
|
+
export function assertWriteScope(issueKey, configuredProject) {
|
|
38
|
+
const project = projectKeyOf(issueKey);
|
|
39
|
+
if (!project) {
|
|
40
|
+
throw new JamError("JAM_WRITE_SCOPE_VIOLATION", `"${issueKey}" is not a Jira issue key.`, { issueKey });
|
|
41
|
+
}
|
|
42
|
+
const configured = configuredProject.trim().toUpperCase();
|
|
43
|
+
if (!configured) {
|
|
44
|
+
throw new JamError("JAM_SETUP_REQUIRED", "No Jira project is configured for this workspace, so JAM cannot tell whether this write is in scope.", { issueKey });
|
|
45
|
+
}
|
|
46
|
+
if (project !== configured) {
|
|
47
|
+
throw new JamError("JAM_WRITE_SCOPE_VIOLATION", `${issueKey} belongs to project ${project}, but this workspace is bound to ${configured}. JAM writes only within the configured project.`, { issueKey, project, configuredProject: configured });
|
|
48
|
+
}
|
|
49
|
+
return project;
|
|
50
|
+
}
|
|
51
|
+
export function assertOperationAllowed(operation) {
|
|
52
|
+
if (!isWriteOperation(operation)) {
|
|
53
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", `"${operation}" is not a JAM write operation. Supported: ${WRITE_OPERATIONS.join(", ")}.`, { operation, supported: [...WRITE_OPERATIONS] });
|
|
54
|
+
}
|
|
55
|
+
return operation;
|
|
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
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Reject anything outside the field whitelist before planning goes further.
|
|
73
|
+
*
|
|
74
|
+
* Returns the requested fields in whitelist order so a plan's `before` and
|
|
75
|
+
* `intendedAfter` are comparable regardless of how the caller ordered them.
|
|
76
|
+
*/
|
|
77
|
+
export function assertFieldsAllowed(input) {
|
|
78
|
+
const requested = Object.keys(input).filter((key) => input[key] !== undefined);
|
|
79
|
+
if (requested.length === 0) {
|
|
80
|
+
throw new JamError("JAM_WRITE_FIELD_NOT_ALLOWED", `field.update needs at least one field. Supported: ${WRITABLE_FIELDS.join(", ")}.`, { supported: [...WRITABLE_FIELDS] });
|
|
81
|
+
}
|
|
82
|
+
const rejected = requested.filter((key) => !isWritableField(key));
|
|
83
|
+
if (rejected.length > 0) {
|
|
84
|
+
throw new JamError("JAM_WRITE_FIELD_NOT_ALLOWED", `JAM cannot write ${rejected.join(", ")}. Supported: ${WRITABLE_FIELDS.join(", ")}.`, { rejected, supported: [...WRITABLE_FIELDS] });
|
|
85
|
+
}
|
|
86
|
+
const ordered = {};
|
|
87
|
+
for (const field of WRITABLE_FIELDS) {
|
|
88
|
+
const value = input[field];
|
|
89
|
+
if (value !== undefined)
|
|
90
|
+
Object.assign(ordered, { [field]: value });
|
|
91
|
+
}
|
|
92
|
+
return ordered;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Match a requested status against what Jira currently offers.
|
|
96
|
+
*
|
|
97
|
+
* Jira's transition ids are per-workflow and not derivable from a status name,
|
|
98
|
+
* so this only ever selects from transitions Jira just reported. When nothing
|
|
99
|
+
* matches, the available names go back with the error - the agent's next move
|
|
100
|
+
* is to pick one of them, not to try harder at the same one.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveTransition(target, available) {
|
|
103
|
+
const wanted = target.trim().toLowerCase();
|
|
104
|
+
const match = available.find((t) => t.to.toLowerCase() === wanted) ??
|
|
105
|
+
available.find((t) => t.name.toLowerCase() === wanted);
|
|
106
|
+
if (!match) {
|
|
107
|
+
throw new JamError("JAM_WRITE_TRANSITION_NOT_AVAILABLE", available.length === 0
|
|
108
|
+
? `Jira offers no transitions from this issue's current status for this account, so it cannot be moved to "${target}".`
|
|
109
|
+
: `"${target}" is not reachable from this issue's current status. Available: ${available.map((t) => t.to).join(", ")}.`, { target, available: available.map((t) => ({ id: t.id, name: t.name, to: t.to })) });
|
|
110
|
+
}
|
|
111
|
+
return match;
|
|
112
|
+
}
|
|
113
|
+
export function planExpired(expiresAt, now) {
|
|
114
|
+
return now.getTime() >= Date.parse(expiresAt);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Has the issue moved since the plan was made?
|
|
118
|
+
*
|
|
119
|
+
* Jira's `updated` timestamp is the revision marker: it changes on any edit,
|
|
120
|
+
* including ones JAM did not make and cannot see the shape of. Comparing it is
|
|
121
|
+
* what stops an agent applying a decision it made against an issue that no
|
|
122
|
+
* longer exists in that form.
|
|
123
|
+
*/
|
|
124
|
+
export function assertUnchanged(issueKey, baseUpdated, currentUpdated) {
|
|
125
|
+
if (baseUpdated === currentUpdated)
|
|
126
|
+
return;
|
|
127
|
+
throw new JamError("JAM_WRITE_CONFLICT", `${issueKey} changed after this plan was made, so the plan no longer describes the issue. Re-plan against the current state.`, { issueKey, planUpdated: baseUpdated, currentUpdated });
|
|
128
|
+
}
|
|
@@ -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,15 @@ 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
|
+
responseBytes: number;
|
|
31
|
+
};
|
|
23
32
|
export type GetIssuesResult = {
|
|
24
33
|
issues: FullIssueContext[];
|
|
25
34
|
/** Keys that were requested but not returned (missing or not permitted). */
|
|
@@ -59,6 +68,20 @@ export type ListProjectsResult = {
|
|
|
59
68
|
};
|
|
60
69
|
export interface JiraReadPort {
|
|
61
70
|
searchPage(req: SearchPageRequest): Promise<SearchPageResult>;
|
|
71
|
+
/**
|
|
72
|
+
* One issue, read directly by key.
|
|
73
|
+
*
|
|
74
|
+
* Separate from `getIssues` because ConsistencyPolicy requires a direct
|
|
75
|
+
* issue GET for anything that decides or confirms a write, and `getIssues`
|
|
76
|
+
* is not one: it is a bulk endpoint that takes a list, and a bulk read is
|
|
77
|
+
* free to answer from a different path than a single-issue GET. The
|
|
78
|
+
* distinction only matters in one place - the write plane - which is exactly
|
|
79
|
+
* where being wrong about it is most expensive.
|
|
80
|
+
*
|
|
81
|
+
* Reads and writes both use it: the pre-write conflict check, the post-write
|
|
82
|
+
* confirmation, and the post-create confirmation.
|
|
83
|
+
*/
|
|
84
|
+
getIssue(req: GetIssueRequest): Promise<GetIssueResult>;
|
|
62
85
|
getIssues(req: GetIssuesRequest): Promise<GetIssuesResult>;
|
|
63
86
|
getComments(req: GetCommentsRequest): Promise<GetCommentsResult>;
|
|
64
87
|
/** Used by `jam doctor` to prove authentication works. */
|
|
@@ -1,12 +1,35 @@
|
|
|
1
|
+
import type { JiraTransition } from "../domain/write.js";
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* The mutating half of Jira, kept apart from reading on purpose.
|
|
4
|
+
*
|
|
5
|
+
* Two rules govern everything behind this port:
|
|
6
|
+
*
|
|
7
|
+
* - A write is not confirmed by its own HTTP response. ConsistencyPolicy
|
|
8
|
+
* requires a direct issue GET afterwards, which is the read port's job -
|
|
9
|
+
* this port never reads back its own work.
|
|
10
|
+
* - Nothing here retries. A read that times out can be repeated; a POST that
|
|
11
|
+
* times out may already have been applied, and repeating it is how one
|
|
12
|
+
* comment becomes two. Ambiguity is resolved by looking, not by trying
|
|
13
|
+
* again.
|
|
14
|
+
*
|
|
15
|
+
* The field map is generic at this layer because Jira's is. The public MCP
|
|
16
|
+
* surface is not: only whitelisted operations reach it (see domain/write.ts).
|
|
6
17
|
*/
|
|
7
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
|
+
}>;
|
|
8
28
|
updateIssue(key: string, fields: Record<string, unknown>): Promise<void>;
|
|
9
29
|
addComment(key: string, body: string): Promise<{
|
|
10
30
|
id: string;
|
|
11
31
|
}>;
|
|
32
|
+
/** Transitions Jira offers for this issue right now, for this account. */
|
|
33
|
+
getTransitions(key: string): Promise<JiraTransition[]>;
|
|
34
|
+
transitionIssue(key: string, transitionId: string): Promise<void>;
|
|
12
35
|
}
|
package/package.json
CHANGED
|
@@ -1,69 +1,69 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@jam-mcp/server",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "JAM (Jira Agent MCP) - agent-facing Jira access layer: MCP server, setup core, and CLI",
|
|
5
|
-
"keywords": [
|
|
6
|
-
"jira",
|
|
7
|
-
"mcp",
|
|
8
|
-
"model-context-protocol",
|
|
9
|
-
"claude-code",
|
|
10
|
-
"codex",
|
|
11
|
-
"jira-api"
|
|
12
|
-
],
|
|
13
|
-
"homepage": "https://github.com/colosair/jam#readme",
|
|
14
|
-
"bugs": {
|
|
15
|
-
"url": "https://github.com/colosair/jam/issues"
|
|
16
|
-
},
|
|
17
|
-
"repository": {
|
|
18
|
-
"type": "git",
|
|
19
|
-
"url": "git+https://github.com/colosair/jam.git",
|
|
20
|
-
"directory": "packages/server"
|
|
21
|
-
},
|
|
22
|
-
"author": "colosair (https://github.com/colosair)",
|
|
23
|
-
"type": "module",
|
|
24
|
-
"bin": {
|
|
25
|
-
"jam-server": "dist/index.js"
|
|
26
|
-
},
|
|
27
|
-
"main": "dist/index.js",
|
|
28
|
-
"types": "dist/index.d.ts",
|
|
29
|
-
"engines": {
|
|
30
|
-
"node": ">=20"
|
|
31
|
-
},
|
|
32
|
-
"files": [
|
|
33
|
-
"dist",
|
|
34
|
-
"!dist/**/*.js.map",
|
|
35
|
-
"README.md"
|
|
36
|
-
],
|
|
37
|
-
"scripts": {
|
|
38
|
-
"build": "tsc",
|
|
39
|
-
"dev": "tsc --watch",
|
|
40
|
-
"test": "vitest run",
|
|
41
|
-
"test:watch": "vitest"
|
|
42
|
-
},
|
|
43
|
-
"dependencies": {
|
|
44
|
-
"@jam-mcp/launcher": "1.0
|
|
45
|
-
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
46
|
-
"yaml": "^2.9.0",
|
|
47
|
-
"zod": "^4.4.3"
|
|
48
|
-
},
|
|
49
|
-
"devDependencies": {
|
|
50
|
-
"@types/node": "^24.0.0",
|
|
51
|
-
"typescript": "^5.9.0",
|
|
52
|
-
"vitest": "^4.1.11"
|
|
53
|
-
},
|
|
54
|
-
"license": "MIT",
|
|
55
|
-
"exports": {
|
|
56
|
-
".": {
|
|
57
|
-
"types": "./dist/index.d.ts",
|
|
58
|
-
"default": "./dist/index.js"
|
|
59
|
-
},
|
|
60
|
-
"./cli-entry": {
|
|
61
|
-
"types": "./dist/cli-entry.d.ts",
|
|
62
|
-
"default": "./dist/cli-entry.js"
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
|
-
"publishConfig": {
|
|
66
|
-
"access": "public",
|
|
67
|
-
"registry": "https://registry.npmjs.org/"
|
|
68
|
-
}
|
|
69
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@jam-mcp/server",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "JAM (Jira Agent MCP) - agent-facing Jira access layer: MCP server, setup core, and CLI",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"jira",
|
|
7
|
+
"mcp",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"claude-code",
|
|
10
|
+
"codex",
|
|
11
|
+
"jira-api"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/colosair/jam#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/colosair/jam/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/colosair/jam.git",
|
|
20
|
+
"directory": "packages/server"
|
|
21
|
+
},
|
|
22
|
+
"author": "colosair (https://github.com/colosair)",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"bin": {
|
|
25
|
+
"jam-server": "dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"main": "dist/index.js",
|
|
28
|
+
"types": "dist/index.d.ts",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"!dist/**/*.js.map",
|
|
35
|
+
"README.md"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc",
|
|
39
|
+
"dev": "tsc --watch",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"test:watch": "vitest"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@jam-mcp/launcher": "1.2.0",
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
46
|
+
"yaml": "^2.9.0",
|
|
47
|
+
"zod": "^4.4.3"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^24.0.0",
|
|
51
|
+
"typescript": "^5.9.0",
|
|
52
|
+
"vitest": "^4.1.11"
|
|
53
|
+
},
|
|
54
|
+
"license": "MIT",
|
|
55
|
+
"exports": {
|
|
56
|
+
".": {
|
|
57
|
+
"types": "./dist/index.d.ts",
|
|
58
|
+
"default": "./dist/index.js"
|
|
59
|
+
},
|
|
60
|
+
"./cli-entry": {
|
|
61
|
+
"types": "./dist/cli-entry.d.ts",
|
|
62
|
+
"default": "./dist/cli-entry.js"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public",
|
|
67
|
+
"registry": "https://registry.npmjs.org/"
|
|
68
|
+
}
|
|
69
|
+
}
|