@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,210 @@
|
|
|
1
|
+
import { JamError } from "../domain/errors.js";
|
|
2
|
+
import { assertExistingIssueOperation, assertFieldsAllowed, assertOperationAllowed, assertWriteScope, PLAN_TTL_MS, resolveTransition, } from "../policy/write-policy.js";
|
|
3
|
+
import { planCreateIssue } from "./plan-create-issue.js";
|
|
4
|
+
/**
|
|
5
|
+
* Work out whether a requested change is currently possible, and describe it.
|
|
6
|
+
*
|
|
7
|
+
* Reads only. Nothing here mutates Jira, and that is the whole point of the
|
|
8
|
+
* step: the agent gets to see what the issue looks like now, what JAM would
|
|
9
|
+
* do to it, and what a direct read will have to show before JAM will call it
|
|
10
|
+
* done - all before anything has happened.
|
|
11
|
+
*
|
|
12
|
+
* The order matters. Scope and operation are checked before any Jira call, so
|
|
13
|
+
* an out-of-scope key costs nothing and comes back as a JAM refusal rather
|
|
14
|
+
* than a 404. Everything after that is derived from the issue as Jira reports
|
|
15
|
+
* it right now, never from what the caller asserted about it.
|
|
16
|
+
*/
|
|
17
|
+
export async function planWrite(deps, request) {
|
|
18
|
+
// Creation branches before anything else touches `key`, because it has none.
|
|
19
|
+
// Routing on the operation rather than on whether a key happened to be
|
|
20
|
+
// supplied keeps the two request shapes genuinely separate instead of one
|
|
21
|
+
// shape with holes in it.
|
|
22
|
+
if (assertOperationAllowed(request.operation) === "issue.create") {
|
|
23
|
+
return planCreateIssue(deps, { input: request.input });
|
|
24
|
+
}
|
|
25
|
+
const issueKey = requireIssueKey(request);
|
|
26
|
+
const projectKey = assertWriteScope(issueKey, deps.config.project.key);
|
|
27
|
+
const operation = assertExistingIssueOperation(request.operation);
|
|
28
|
+
// Everything that can be refused from the request alone is refused here,
|
|
29
|
+
// before a Jira call is spent on it. An agent asking to write a field JAM
|
|
30
|
+
// does not write should get that answer, not a round trip and then that
|
|
31
|
+
// answer.
|
|
32
|
+
const input = validateInput(operation, request.input);
|
|
33
|
+
const issue = await readIssue(deps, issueKey);
|
|
34
|
+
const { before, intendedAfter, mutation, transition } = await describe(deps, operation, issueKey, issue, input);
|
|
35
|
+
const createdAt = new Date();
|
|
36
|
+
const plan = deps.writePlans.create({
|
|
37
|
+
kind: "existing-issue",
|
|
38
|
+
issueKey,
|
|
39
|
+
projectKey,
|
|
40
|
+
operation,
|
|
41
|
+
before,
|
|
42
|
+
intendedAfter,
|
|
43
|
+
baseUpdated: issue.updated,
|
|
44
|
+
createdAt: createdAt.toISOString(),
|
|
45
|
+
expiresAt: new Date(createdAt.getTime() + PLAN_TTL_MS).toISOString(),
|
|
46
|
+
...(transition ? { transition } : {}),
|
|
47
|
+
mutation,
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
plan,
|
|
51
|
+
receipt: {
|
|
52
|
+
status: "planned",
|
|
53
|
+
planId: plan.planId,
|
|
54
|
+
issue: plan.issueKey,
|
|
55
|
+
operation: plan.operation,
|
|
56
|
+
before: plan.before,
|
|
57
|
+
intendedAfter: plan.intendedAfter,
|
|
58
|
+
expiresAt: plan.expiresAt,
|
|
59
|
+
verification: { method: "direct-issue-read", expects: plan.intendedAfter },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The issue as Jira has it, read directly by key.
|
|
65
|
+
*
|
|
66
|
+
* `getIssue`, not `getIssues`: ConsistencyPolicy requires a direct issue GET
|
|
67
|
+
* for anything that decides or confirms a write, and the bulk endpoint is not
|
|
68
|
+
* one. A JQL result can lag behind the issue it describes; a bulk fetch is
|
|
69
|
+
* free to answer from a different path than the single-issue endpoint. Neither
|
|
70
|
+
* difference matters for ordinary reads, and both matter here.
|
|
71
|
+
*
|
|
72
|
+
* The one read every write goes through - the pre-write conflict check, the
|
|
73
|
+
* post-write confirmation, and the post-create confirmation.
|
|
74
|
+
*/
|
|
75
|
+
export async function readIssue(deps, issueKey) {
|
|
76
|
+
const { issue: found } = await deps.jira.getIssue({
|
|
77
|
+
key: issueKey,
|
|
78
|
+
// `issuetype` and `description` are here for creation's verification step,
|
|
79
|
+
// which has to confirm the issue Jira made is the one that was asked for.
|
|
80
|
+
// A field a plan promises to check has to be a field this read returns -
|
|
81
|
+
// otherwise the check silently passes on `undefined`. They cost nothing on
|
|
82
|
+
// the other operations, which do not compare them.
|
|
83
|
+
fields: [
|
|
84
|
+
"summary",
|
|
85
|
+
"status",
|
|
86
|
+
"issuetype",
|
|
87
|
+
"description",
|
|
88
|
+
"priority",
|
|
89
|
+
"labels",
|
|
90
|
+
"components",
|
|
91
|
+
"updated",
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
if (!found) {
|
|
95
|
+
throw new JamError("ISSUE_NOT_FOUND", `Jira has no issue ${issueKey}, or it is not visible to this account.`, { issueKey });
|
|
96
|
+
}
|
|
97
|
+
return found;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The issue an existing-issue operation names, or a refusal that says why.
|
|
101
|
+
*
|
|
102
|
+
* `key` is optional on the request only because `issue.create` has no issue.
|
|
103
|
+
* Reaching here means the operation does have one, so its absence is the
|
|
104
|
+
* caller using the wrong shape - which is worth saying, rather than reading as
|
|
105
|
+
* an empty key and failing further in.
|
|
106
|
+
*/
|
|
107
|
+
function requireIssueKey(request) {
|
|
108
|
+
const key = request.key?.trim();
|
|
109
|
+
if (!key) {
|
|
110
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", `${request.operation} changes an issue that already exists, so it needs \`key\`.`, { operation: request.operation });
|
|
111
|
+
}
|
|
112
|
+
return key.toUpperCase();
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Check the request against the contract, and normalize it.
|
|
116
|
+
*
|
|
117
|
+
* Pure: no Jira, no state. Whether an operation is supported, whether a field
|
|
118
|
+
* is writable and whether the input is even the right shape are all knowable
|
|
119
|
+
* without asking Jira anything, so they are answered first.
|
|
120
|
+
*/
|
|
121
|
+
function validateInput(operation, raw) {
|
|
122
|
+
switch (operation) {
|
|
123
|
+
case "comment.add": {
|
|
124
|
+
const text = raw.text;
|
|
125
|
+
if (typeof text !== "string" || text.trim().length === 0) {
|
|
126
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "comment.add needs non-empty `input.text`.", { operation });
|
|
127
|
+
}
|
|
128
|
+
return { text: text.trim() };
|
|
129
|
+
}
|
|
130
|
+
case "field.update":
|
|
131
|
+
return assertFieldsAllowed(raw);
|
|
132
|
+
case "status.transition": {
|
|
133
|
+
const status = raw.status;
|
|
134
|
+
if (typeof status !== "string" || status.trim().length === 0) {
|
|
135
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "status.transition needs non-empty `input.status`.", { operation });
|
|
136
|
+
}
|
|
137
|
+
return { status: status.trim() };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function describe(deps, operation, issueKey, issue, input) {
|
|
142
|
+
switch (operation) {
|
|
143
|
+
case "comment.add": {
|
|
144
|
+
const { text } = input;
|
|
145
|
+
// Comments accumulate rather than replace, so `before` says what is
|
|
146
|
+
// there now by count - the plan is not claiming to know the thread.
|
|
147
|
+
return {
|
|
148
|
+
before: { comments: issue.comments.length },
|
|
149
|
+
intendedAfter: { commentAdded: text },
|
|
150
|
+
mutation: { kind: "comment", text },
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
case "field.update": {
|
|
154
|
+
const fields = input;
|
|
155
|
+
const before = {};
|
|
156
|
+
const after = {};
|
|
157
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
158
|
+
before[field] = currentValue(issue, field);
|
|
159
|
+
after[field] = value;
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
before,
|
|
163
|
+
intendedAfter: after,
|
|
164
|
+
mutation: { kind: "fields", fields: toJiraFields(fields) },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
case "status.transition": {
|
|
168
|
+
const { status: target } = input;
|
|
169
|
+
// Ask Jira what is reachable rather than deriving an id from a name:
|
|
170
|
+
// transition ids are per-workflow, and a guessed one either fails or
|
|
171
|
+
// moves the issue somewhere nobody asked for.
|
|
172
|
+
const available = await deps.jiraWrite.getTransitions(issueKey);
|
|
173
|
+
const transition = resolveTransition(target, available);
|
|
174
|
+
return {
|
|
175
|
+
before: { status: issue.status },
|
|
176
|
+
intendedAfter: { status: transition.to },
|
|
177
|
+
mutation: { kind: "transition", transitionId: transition.id },
|
|
178
|
+
transition,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function currentValue(issue, field) {
|
|
184
|
+
switch (field) {
|
|
185
|
+
case "summary":
|
|
186
|
+
return issue.summary;
|
|
187
|
+
case "priority":
|
|
188
|
+
return issue.priority;
|
|
189
|
+
case "labels":
|
|
190
|
+
return issue.labels;
|
|
191
|
+
case "components":
|
|
192
|
+
return issue.components;
|
|
193
|
+
default:
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Whitelisted values to the shapes Jira's field API expects. */
|
|
198
|
+
function toJiraFields(input) {
|
|
199
|
+
const fields = {};
|
|
200
|
+
if (input.summary !== undefined)
|
|
201
|
+
fields["summary"] = input.summary;
|
|
202
|
+
if (input.priority !== undefined)
|
|
203
|
+
fields["priority"] = { name: input.priority };
|
|
204
|
+
if (input.labels !== undefined)
|
|
205
|
+
fields["labels"] = input.labels;
|
|
206
|
+
if (input.components !== undefined) {
|
|
207
|
+
fields["components"] = input.components.map((name) => ({ name }));
|
|
208
|
+
}
|
|
209
|
+
return fields;
|
|
210
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { NewWritePlan, WritePlan } from "../domain/write.js";
|
|
2
|
+
/**
|
|
3
|
+
* Where a plan lives between `jira_write_plan` and `jira_write_apply`.
|
|
4
|
+
*
|
|
5
|
+
* In this process, and nowhere else. The alternative considered was a signed
|
|
6
|
+
* self-contained token, and it is worse here on both counts that matter: the
|
|
7
|
+
* signing key would have to come from somewhere (a new secret on disk, or a
|
|
8
|
+
* per-process key that gives the token exactly this lifetime anyway), and the
|
|
9
|
+
* mutation would have to travel through the agent to come back. Keeping the
|
|
10
|
+
* mutation in memory makes forgery impossible rather than merely hard - a
|
|
11
|
+
* `planId` is an opaque handle, and what it names never leaves this process.
|
|
12
|
+
*
|
|
13
|
+
* The cost is that plans do not survive a restart. That is acceptable: a plan
|
|
14
|
+
* is only valid while the issue has not moved, so a stale one was going to be
|
|
15
|
+
* rejected on its own terms, and re-planning is a single read.
|
|
16
|
+
*
|
|
17
|
+
* See docs/decisions/adr-jira-write-plane.md.
|
|
18
|
+
*/
|
|
19
|
+
export declare class WritePlanStore {
|
|
20
|
+
private readonly now;
|
|
21
|
+
private readonly plans;
|
|
22
|
+
/** Injected by tests so expiry does not depend on wall-clock timing. */
|
|
23
|
+
constructor(now?: () => Date);
|
|
24
|
+
create(plan: NewWritePlan): WritePlan;
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a plan for applying.
|
|
27
|
+
*
|
|
28
|
+
* An expired plan is reported as expired rather than as missing: those are
|
|
29
|
+
* different situations, and telling them apart is the difference between
|
|
30
|
+
* "re-plan" and "you are calling this wrong".
|
|
31
|
+
*/
|
|
32
|
+
take(planId: string): WritePlan;
|
|
33
|
+
/**
|
|
34
|
+
* Retire a plan once it has been applied.
|
|
35
|
+
*
|
|
36
|
+
* Single use, so a receipt cannot be turned into a second mutation by
|
|
37
|
+
* calling apply again with the same id - which for `comment.add` would mean
|
|
38
|
+
* two comments.
|
|
39
|
+
*/
|
|
40
|
+
consume(planId: string): void;
|
|
41
|
+
private evictExpired;
|
|
42
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { JamError } from "../domain/errors.js";
|
|
3
|
+
import { planExpired } from "../policy/write-policy.js";
|
|
4
|
+
/**
|
|
5
|
+
* Where a plan lives between `jira_write_plan` and `jira_write_apply`.
|
|
6
|
+
*
|
|
7
|
+
* In this process, and nowhere else. The alternative considered was a signed
|
|
8
|
+
* self-contained token, and it is worse here on both counts that matter: the
|
|
9
|
+
* signing key would have to come from somewhere (a new secret on disk, or a
|
|
10
|
+
* per-process key that gives the token exactly this lifetime anyway), and the
|
|
11
|
+
* mutation would have to travel through the agent to come back. Keeping the
|
|
12
|
+
* mutation in memory makes forgery impossible rather than merely hard - a
|
|
13
|
+
* `planId` is an opaque handle, and what it names never leaves this process.
|
|
14
|
+
*
|
|
15
|
+
* The cost is that plans do not survive a restart. That is acceptable: a plan
|
|
16
|
+
* is only valid while the issue has not moved, so a stale one was going to be
|
|
17
|
+
* rejected on its own terms, and re-planning is a single read.
|
|
18
|
+
*
|
|
19
|
+
* See docs/decisions/adr-jira-write-plane.md.
|
|
20
|
+
*/
|
|
21
|
+
export class WritePlanStore {
|
|
22
|
+
now;
|
|
23
|
+
plans = new Map();
|
|
24
|
+
/** Injected by tests so expiry does not depend on wall-clock timing. */
|
|
25
|
+
constructor(now = () => new Date()) {
|
|
26
|
+
this.now = now;
|
|
27
|
+
}
|
|
28
|
+
create(plan) {
|
|
29
|
+
this.evictExpired();
|
|
30
|
+
const stored = { ...plan, planId: randomUUID() };
|
|
31
|
+
this.plans.set(stored.planId, stored);
|
|
32
|
+
return stored;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a plan for applying.
|
|
36
|
+
*
|
|
37
|
+
* An expired plan is reported as expired rather than as missing: those are
|
|
38
|
+
* different situations, and telling them apart is the difference between
|
|
39
|
+
* "re-plan" and "you are calling this wrong".
|
|
40
|
+
*/
|
|
41
|
+
take(planId) {
|
|
42
|
+
const plan = this.plans.get(planId);
|
|
43
|
+
if (!plan) {
|
|
44
|
+
throw new JamError("JAM_WRITE_PLAN_NOT_FOUND", "No such write plan. Plans live in the running JAM server and do not survive a restart - call jira_write_plan again.", { planId });
|
|
45
|
+
}
|
|
46
|
+
if (planExpired(plan.expiresAt, this.now())) {
|
|
47
|
+
this.plans.delete(planId);
|
|
48
|
+
// What to re-plan against differs by plan: an existing issue has a
|
|
49
|
+
// current state, a create has only the project's current create schema.
|
|
50
|
+
// Naming an issue key here for a create would name an issue that has
|
|
51
|
+
// never existed.
|
|
52
|
+
throw new JamError("JAM_WRITE_PLAN_EXPIRED", plan.kind === "create-issue"
|
|
53
|
+
? `This write plan expired at ${plan.expiresAt}. Nothing was created - re-plan against the current create schema for project ${plan.projectKey}.`
|
|
54
|
+
: `This write plan expired at ${plan.expiresAt}. Re-plan against the current state of ${plan.issueKey}.`, {
|
|
55
|
+
planId,
|
|
56
|
+
expiresAt: plan.expiresAt,
|
|
57
|
+
...(plan.kind === "create-issue"
|
|
58
|
+
? { project: plan.projectKey }
|
|
59
|
+
: { issueKey: plan.issueKey }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return plan;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Retire a plan once it has been applied.
|
|
66
|
+
*
|
|
67
|
+
* Single use, so a receipt cannot be turned into a second mutation by
|
|
68
|
+
* calling apply again with the same id - which for `comment.add` would mean
|
|
69
|
+
* two comments.
|
|
70
|
+
*/
|
|
71
|
+
consume(planId) {
|
|
72
|
+
this.plans.delete(planId);
|
|
73
|
+
}
|
|
74
|
+
evictExpired() {
|
|
75
|
+
const now = this.now();
|
|
76
|
+
for (const [id, plan] of this.plans) {
|
|
77
|
+
if (planExpired(plan.expiresAt, now))
|
|
78
|
+
this.plans.delete(id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { toJamError } from "../domain/errors.js";
|
|
2
|
-
import { createServer } from "../mcp/create-server.js";
|
|
2
|
+
import { createServer, TOOL_COUNT } from "../mcp/create-server.js";
|
|
3
3
|
/**
|
|
4
4
|
* One health-check core shared by `jam doctor`, `jam setup` and `jam serve`.
|
|
5
5
|
*
|
|
@@ -60,7 +60,7 @@ export async function runHealthGate(deps, mode) {
|
|
|
60
60
|
});
|
|
61
61
|
try {
|
|
62
62
|
createServer(deps);
|
|
63
|
-
add({ name: "MCP server startup", ok: true, fatal: true, detail:
|
|
63
|
+
add({ name: "MCP server startup", ok: true, fatal: true, detail: `${TOOL_COUNT} tools registered` });
|
|
64
64
|
}
|
|
65
65
|
catch (err) {
|
|
66
66
|
add({ name: "MCP server startup", ok: false, fatal: true, detail: toJamError(err).message });
|
|
@@ -13,7 +13,7 @@ import { LAUNCHER_PACKAGE_SPEC } from "@jam-mcp/launcher";
|
|
|
13
13
|
export { LAUNCHER_PACKAGE_SPEC };
|
|
14
14
|
export declare const JAM_MCP_ENTRY: {
|
|
15
15
|
readonly command: "npx";
|
|
16
|
-
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.0
|
|
16
|
+
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.2.0", "serve"];
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* Recognise wiring from before the launcher existed: a hard-coded path to one
|
package/dist/cli/auth.d.ts
CHANGED
|
@@ -24,6 +24,12 @@ export type AuthOptions = {
|
|
|
24
24
|
* it) is about the ordering, not about the network.
|
|
25
25
|
*/
|
|
26
26
|
verify?: (values: StoredCredentials) => Promise<string | undefined>;
|
|
27
|
+
/**
|
|
28
|
+
* Injected by tests. The suite runs with JAM_DISABLE_SECRET_STORE set so it
|
|
29
|
+
* cannot reach a real keychain, which would otherwise make every "no store
|
|
30
|
+
* on this system" case report the disabled one instead.
|
|
31
|
+
*/
|
|
32
|
+
env?: NodeJS.ProcessEnv;
|
|
27
33
|
};
|
|
28
34
|
export declare function authLoginCommand(options?: AuthOptions): Promise<number>;
|
|
29
35
|
export declare function authLogoutCommand(options?: AuthOptions): number;
|
package/dist/cli/auth.js
CHANGED
|
@@ -48,11 +48,12 @@ export async function authLoginCommand(options = {}) {
|
|
|
48
48
|
const ui = options.ui ?? new Ui();
|
|
49
49
|
const store = "store" in options ? options.store : resolveSecretStore();
|
|
50
50
|
const readBack = options.readBack ?? freshPort;
|
|
51
|
+
const env = options.env ?? process.env;
|
|
51
52
|
ui.section("Authentication");
|
|
52
53
|
if (!store) {
|
|
53
54
|
// Disabled and absent are different problems with different fixes, and
|
|
54
55
|
// saying "no store" when one was switched off sends the user hunting.
|
|
55
|
-
if (secretStoreDisabled()) {
|
|
56
|
+
if (secretStoreDisabled(env)) {
|
|
56
57
|
ui.failure("Secret store disabled by JAM_DISABLE_SECRET_STORE");
|
|
57
58
|
ui.line(" That variable is for isolated test sandboxes. Unset it and run this again.");
|
|
58
59
|
ui.next(ENV_HINT);
|
package/dist/cli-entry.js
CHANGED
|
@@ -11,39 +11,39 @@ import { authStatusCommand, doctorJsonCommand, setupAgentCommand, setupApplyComm
|
|
|
11
11
|
* points (notably @jam-mcp/bootstrap) can forward to exactly these commands
|
|
12
12
|
* instead of reimplementing them.
|
|
13
13
|
*/
|
|
14
|
-
export const USAGE = `jam - Jira Agent MCP
|
|
15
|
-
|
|
16
|
-
Usage:
|
|
17
|
-
jam serve Run the MCP server over stdio (default; this is what Claude Code / Codex launch)
|
|
18
|
-
jam doctor Diagnose config, credentials and Jira connectivity
|
|
19
|
-
jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]
|
|
20
|
-
Wire up this project and run doctor. Binds it to you
|
|
21
|
-
alone, writing nothing to the repository; --shared
|
|
22
|
-
adopts JAM for the team (project.yaml, .mcp.json)
|
|
23
|
-
jam runtime Show which JAM build this machine runs
|
|
24
|
-
jam runtime use package | development <path>
|
|
25
|
-
Change it (writes ~/.jam/config.yaml only, never a project)
|
|
26
|
-
jam auth login Store Jira credentials in this user's OS secret store
|
|
27
|
-
jam auth logout Remove them again
|
|
28
|
-
|
|
29
|
-
For coding agents and scripts (stdout is JSON only, never prompts):
|
|
30
|
-
jam setup --agent One shot: detect, plan, apply what is safe, verify
|
|
31
|
-
jam setup plan --json Report what setup would change, changing nothing
|
|
32
|
-
jam setup apply --non-interactive --json
|
|
33
|
-
Execute the plan
|
|
34
|
-
jam doctor --json Health check as structured output
|
|
35
|
-
jam auth status --json Whether Jira credentials are configured (never their value)
|
|
36
|
-
|
|
37
|
-
Environment:
|
|
38
|
-
JIRA_BASE_URL https://your-site.atlassian.net
|
|
39
|
-
JIRA_EMAIL Atlassian account email
|
|
40
|
-
JIRA_API_TOKEN Atlassian API token
|
|
41
|
-
JAM_PROJECT_KEY Jira project key, used by \`jam setup\`/\`jam serve\` when no
|
|
42
|
-
.jira-agent/project.yaml exists yet
|
|
43
|
-
|
|
44
|
-
Credentials and JAM_PROJECT_KEY are read from the current shell's environment
|
|
45
|
-
first, then (on Windows) from the User environment - so a value set with
|
|
46
|
-
\`setx\` works without opening a new terminal.
|
|
14
|
+
export const USAGE = `jam - Jira Agent MCP
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
jam serve Run the MCP server over stdio (default; this is what Claude Code / Codex launch)
|
|
18
|
+
jam doctor Diagnose config, credentials and Jira connectivity
|
|
19
|
+
jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]
|
|
20
|
+
Wire up this project and run doctor. Binds it to you
|
|
21
|
+
alone, writing nothing to the repository; --shared
|
|
22
|
+
adopts JAM for the team (project.yaml, .mcp.json)
|
|
23
|
+
jam runtime Show which JAM build this machine runs
|
|
24
|
+
jam runtime use package | development <path>
|
|
25
|
+
Change it (writes ~/.jam/config.yaml only, never a project)
|
|
26
|
+
jam auth login Store Jira credentials in this user's OS secret store
|
|
27
|
+
jam auth logout Remove them again
|
|
28
|
+
|
|
29
|
+
For coding agents and scripts (stdout is JSON only, never prompts):
|
|
30
|
+
jam setup --agent One shot: detect, plan, apply what is safe, verify
|
|
31
|
+
jam setup plan --json Report what setup would change, changing nothing
|
|
32
|
+
jam setup apply --non-interactive --json
|
|
33
|
+
Execute the plan
|
|
34
|
+
jam doctor --json Health check as structured output
|
|
35
|
+
jam auth status --json Whether Jira credentials are configured (never their value)
|
|
36
|
+
|
|
37
|
+
Environment:
|
|
38
|
+
JIRA_BASE_URL https://your-site.atlassian.net
|
|
39
|
+
JIRA_EMAIL Atlassian account email
|
|
40
|
+
JIRA_API_TOKEN Atlassian API token
|
|
41
|
+
JAM_PROJECT_KEY Jira project key, used by \`jam setup\`/\`jam serve\` when no
|
|
42
|
+
.jira-agent/project.yaml exists yet
|
|
43
|
+
|
|
44
|
+
Credentials and JAM_PROJECT_KEY are read from the current shell's environment
|
|
45
|
+
first, then (on Windows) from the User environment - so a value set with
|
|
46
|
+
\`setx\` works without opening a new terminal.
|
|
47
47
|
`;
|
|
48
48
|
function findFlagValue(argv, flag) {
|
|
49
49
|
const index = argv.indexOf(flag);
|
package/dist/deps.d.ts
CHANGED
|
@@ -4,6 +4,9 @@ import type { ProjectConfig } from "./config/schema.js";
|
|
|
4
4
|
import type { CachePort } from "./ports/cache.port.js";
|
|
5
5
|
import type { CredentialPort } from "./ports/credentials.port.js";
|
|
6
6
|
import type { JiraReadPort } from "./ports/jira-read.port.js";
|
|
7
|
+
import type { JiraCreateMetadataPort } from "./ports/jira-create-metadata.port.js";
|
|
8
|
+
import type { JiraWritePort } from "./ports/jira-write.port.js";
|
|
9
|
+
import { WritePlanStore } from "./application/write-plan-store.js";
|
|
7
10
|
import type { TelemetryPort } from "./ports/telemetry.port.js";
|
|
8
11
|
/** Everything the application layer is allowed to reach for. */
|
|
9
12
|
export type JamDeps = {
|
|
@@ -12,6 +15,25 @@ export type JamDeps = {
|
|
|
12
15
|
/** Where the project key came from when no config file supplied one. */
|
|
13
16
|
keySource?: BootstrapSource;
|
|
14
17
|
jira: JiraReadPort;
|
|
18
|
+
/**
|
|
19
|
+
* The mutating half. Separate from `jira` on purpose: reading and writing
|
|
20
|
+
* have different retry rules and different confirmation rules, and one port
|
|
21
|
+
* that did both would blur them.
|
|
22
|
+
*/
|
|
23
|
+
jiraWrite: JiraWritePort;
|
|
24
|
+
/**
|
|
25
|
+
* What Jira will accept when creating an issue here. A third port rather
|
|
26
|
+
* than a method on either of the others: it mutates nothing, so it does not
|
|
27
|
+
* belong behind the write port's no-retry contract, and it answers a
|
|
28
|
+
* question about a project rather than about an issue, so the read port's
|
|
29
|
+
* completeness semantics would mean nothing for it.
|
|
30
|
+
*/
|
|
31
|
+
jiraCreateMetadata: JiraCreateMetadataPort;
|
|
32
|
+
/**
|
|
33
|
+
* Plans awaiting apply. Lives for the life of this server process - see
|
|
34
|
+
* WritePlanStore for why it is not persisted.
|
|
35
|
+
*/
|
|
36
|
+
writePlans: WritePlanStore;
|
|
15
37
|
cache: CachePort;
|
|
16
38
|
telemetry: TelemetryPort;
|
|
17
39
|
credentials: CredentialPort;
|
|
@@ -20,6 +42,10 @@ export type BuildDepsOptions = {
|
|
|
20
42
|
cwd?: string;
|
|
21
43
|
/** Injected by tests to bypass the real REST adapter. */
|
|
22
44
|
jira?: JiraReadPort;
|
|
45
|
+
/** Injected by tests so no test can reach a real Jira write endpoint. */
|
|
46
|
+
jiraWrite?: JiraWritePort;
|
|
47
|
+
/** Injected by tests so create metadata comes from a fixture, not a site. */
|
|
48
|
+
jiraCreateMetadata?: JiraCreateMetadataPort;
|
|
23
49
|
/** Injected by tests to bypass the real process/registry credential lookup. */
|
|
24
50
|
credentials?: CredentialPort;
|
|
25
51
|
/**
|
package/dist/deps.js
CHANGED
|
@@ -2,6 +2,7 @@ import { NoopCache } from "./adapters/cache/noop-cache.js";
|
|
|
2
2
|
import { CompositeCredentialProvider } from "./adapters/credentials/composite.js";
|
|
3
3
|
import { ConsoleTelemetry } from "./adapters/telemetry/console-telemetry.js";
|
|
4
4
|
import { resolveProjectConfig } from "./bootstrap/project-config-resolver.js";
|
|
5
|
+
import { WritePlanStore } from "./application/write-plan-store.js";
|
|
5
6
|
/**
|
|
6
7
|
* Single composition root. `jam serve`, `jam doctor` and `jam setup` all wire
|
|
7
8
|
* through here, so a doctor pass actually proves the server's configuration.
|
|
@@ -27,11 +28,24 @@ export async function buildDeps(options = {}) {
|
|
|
27
28
|
const { JiraCloudReadAdapter } = await import("./adapters/jira-cloud/jira-read.adapter.js");
|
|
28
29
|
jira = new JiraCloudReadAdapter(credentials, resolved.config);
|
|
29
30
|
}
|
|
31
|
+
let jiraWrite = options.jiraWrite;
|
|
32
|
+
if (!jiraWrite) {
|
|
33
|
+
const { JiraCloudWriteAdapter } = await import("./adapters/jira-cloud/jira-write.adapter.js");
|
|
34
|
+
jiraWrite = new JiraCloudWriteAdapter(credentials);
|
|
35
|
+
}
|
|
36
|
+
let jiraCreateMetadata = options.jiraCreateMetadata;
|
|
37
|
+
if (!jiraCreateMetadata) {
|
|
38
|
+
const { JiraCloudCreateMetadataAdapter } = await import("./adapters/jira-cloud/jira-create-metadata.adapter.js");
|
|
39
|
+
jiraCreateMetadata = new JiraCloudCreateMetadataAdapter(credentials);
|
|
40
|
+
}
|
|
30
41
|
return {
|
|
31
42
|
config: resolved.config,
|
|
32
43
|
configPath: resolved.configPath,
|
|
33
44
|
keySource: resolved.keySource,
|
|
34
45
|
jira,
|
|
46
|
+
jiraWrite,
|
|
47
|
+
jiraCreateMetadata,
|
|
48
|
+
writePlans: new WritePlanStore(),
|
|
35
49
|
cache: new NoopCache(),
|
|
36
50
|
telemetry,
|
|
37
51
|
credentials,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain text to the narrowest Atlassian Document Format that represents it.
|
|
3
|
+
*
|
|
4
|
+
* Jira's rich-text fields - a comment body, an issue description - are document
|
|
5
|
+
* trees. Accepting one from a caller would mean accepting arbitrary structure
|
|
6
|
+
* through a field that reads like "text": panels, mentions, embedded content,
|
|
7
|
+
* links to anywhere. So JAM's contract is plain text in both places, and this
|
|
8
|
+
* is the single conversion that produces the document.
|
|
9
|
+
*
|
|
10
|
+
* Shared rather than duplicated per field on purpose. Two copies of this would
|
|
11
|
+
* be two answers to "what can an agent put in a Jira document", and the second
|
|
12
|
+
* one would drift.
|
|
13
|
+
*
|
|
14
|
+
* Blank lines separate paragraphs; everything else is literal. No markdown is
|
|
15
|
+
* interpreted, so text containing `*` or `#` says what it says.
|
|
16
|
+
*/
|
|
17
|
+
export declare function textToAdf(text: string): unknown;
|
|
18
|
+
/**
|
|
19
|
+
* The text as it will exist once Jira has it.
|
|
20
|
+
*
|
|
21
|
+
* A write is only verified if what a direct read shows can be compared to what
|
|
22
|
+
* was asked for - and for a rich-text field those two are never byte-identical.
|
|
23
|
+
* The caller's string becomes a document, Jira stores the document, and reading
|
|
24
|
+
* it back renders a document into text again. Blank-line runs collapse, block
|
|
25
|
+
* edges lose their whitespace, a trailing newline disappears.
|
|
26
|
+
*
|
|
27
|
+
* None of that changes what the description says, so comparing raw strings
|
|
28
|
+
* would fail every time. Comparing canonical forms fails only when the text
|
|
29
|
+
* actually differs.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately the same normalization `textToAdf` performs, from the same
|
|
32
|
+
* place: "what JAM sends" and "what JAM will accept as proof it arrived" must
|
|
33
|
+
* not be able to drift into two answers.
|
|
34
|
+
*/
|
|
35
|
+
export declare function canonicalizePlainText(text: string): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain text to the narrowest Atlassian Document Format that represents it.
|
|
3
|
+
*
|
|
4
|
+
* Jira's rich-text fields - a comment body, an issue description - are document
|
|
5
|
+
* trees. Accepting one from a caller would mean accepting arbitrary structure
|
|
6
|
+
* through a field that reads like "text": panels, mentions, embedded content,
|
|
7
|
+
* links to anywhere. So JAM's contract is plain text in both places, and this
|
|
8
|
+
* is the single conversion that produces the document.
|
|
9
|
+
*
|
|
10
|
+
* Shared rather than duplicated per field on purpose. Two copies of this would
|
|
11
|
+
* be two answers to "what can an agent put in a Jira document", and the second
|
|
12
|
+
* one would drift.
|
|
13
|
+
*
|
|
14
|
+
* Blank lines separate paragraphs; everything else is literal. No markdown is
|
|
15
|
+
* interpreted, so text containing `*` or `#` says what it says.
|
|
16
|
+
*/
|
|
17
|
+
export function textToAdf(text) {
|
|
18
|
+
const paragraphs = toParagraphs(text);
|
|
19
|
+
return {
|
|
20
|
+
type: "doc",
|
|
21
|
+
version: 1,
|
|
22
|
+
content: (paragraphs.length > 0 ? paragraphs : [text]).map((block) => ({
|
|
23
|
+
type: "paragraph",
|
|
24
|
+
content: [{ type: "text", text: block }],
|
|
25
|
+
})),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The text as it will exist once Jira has it.
|
|
30
|
+
*
|
|
31
|
+
* A write is only verified if what a direct read shows can be compared to what
|
|
32
|
+
* was asked for - and for a rich-text field those two are never byte-identical.
|
|
33
|
+
* The caller's string becomes a document, Jira stores the document, and reading
|
|
34
|
+
* it back renders a document into text again. Blank-line runs collapse, block
|
|
35
|
+
* edges lose their whitespace, a trailing newline disappears.
|
|
36
|
+
*
|
|
37
|
+
* None of that changes what the description says, so comparing raw strings
|
|
38
|
+
* would fail every time. Comparing canonical forms fails only when the text
|
|
39
|
+
* actually differs.
|
|
40
|
+
*
|
|
41
|
+
* Deliberately the same normalization `textToAdf` performs, from the same
|
|
42
|
+
* place: "what JAM sends" and "what JAM will accept as proof it arrived" must
|
|
43
|
+
* not be able to drift into two answers.
|
|
44
|
+
*/
|
|
45
|
+
export function canonicalizePlainText(text) {
|
|
46
|
+
return toParagraphs(text).join("\n\n");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Blank lines separate paragraphs; everything else is literal.
|
|
50
|
+
*
|
|
51
|
+
* A single newline inside a block survives - it is a line break the author
|
|
52
|
+
* wrote, and ADF round-trips it - so only the edges of each block are trimmed.
|
|
53
|
+
*/
|
|
54
|
+
function toParagraphs(text) {
|
|
55
|
+
return (text
|
|
56
|
+
// Line endings first. A CRLF document would otherwise carry a stray \r
|
|
57
|
+
// at the end of every block into ADF, and Jira renders it back as LF -
|
|
58
|
+
// so a create that was correct would fail verification, on nothing more
|
|
59
|
+
// than which editor the caller used. Worse, `\r\n\r\n` and `\n\n` would
|
|
60
|
+
// split into paragraphs differently.
|
|
61
|
+
.replace(/\r\n?/g, "\n")
|
|
62
|
+
.split(/\n{2,}/)
|
|
63
|
+
.map((block) => block.trim())
|
|
64
|
+
.filter(Boolean));
|
|
65
|
+
}
|
package/dist/domain/errors.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* is mapped onto one of these codes so the agent (and `jam doctor`) can reason
|
|
6
6
|
* about failures without parsing vendor-specific payloads.
|
|
7
7
|
*/
|
|
8
|
-
export declare const JAM_ERROR_CODES: readonly ["JIRA_AUTH_FAILED", "JIRA_PERMISSION_DENIED", "JQL_INVALID", "ISSUE_NOT_FOUND", "RATE_LIMITED", "CONTEXT_TOO_LARGE", "PARTIAL_RESULT", "CONFIG_INVALID", "JIRA_UNAVAILABLE", "JAM_SETUP_REQUIRED", "JAM_BINDINGS_UNREADABLE"];
|
|
8
|
+
export declare const JAM_ERROR_CODES: readonly ["JIRA_AUTH_FAILED", "JIRA_PERMISSION_DENIED", "JQL_INVALID", "ISSUE_NOT_FOUND", "RATE_LIMITED", "CONTEXT_TOO_LARGE", "PARTIAL_RESULT", "CONFIG_INVALID", "JIRA_UNAVAILABLE", "JAM_SETUP_REQUIRED", "JAM_BINDINGS_UNREADABLE", "JAM_WRITE_SCOPE_VIOLATION", "JAM_WRITE_OPERATION_NOT_ALLOWED", "JAM_WRITE_FIELD_NOT_ALLOWED", "JAM_WRITE_TRANSITION_NOT_AVAILABLE", "JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE", "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED", "JAM_WRITE_VALUE_NOT_ALLOWED", "JAM_WRITE_SCHEMA_CHANGED", "JAM_WRITE_PLAN_NOT_FOUND", "JAM_WRITE_PLAN_EXPIRED", "JAM_WRITE_CONFLICT", "JAM_WRITE_VERIFICATION_FAILED", "JAM_WRITE_UNCERTAIN"];
|
|
9
9
|
export type JamErrorCode = (typeof JAM_ERROR_CODES)[number];
|
|
10
10
|
export type JamErrorPayload = {
|
|
11
11
|
error: {
|