@jam-mcp/server 1.4.3 → 1.4.5
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/composite.js +9 -0
- package/dist/adapters/jira-cloud/mapper.js +25 -0
- package/dist/application/apply-create-issue.js +14 -4
- package/dist/application/apply-write.js +10 -1
- package/dist/application/plan-write.d.ts +10 -0
- package/dist/application/plan-write.js +16 -1
- package/dist/application/search-issues.js +9 -0
- package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
- package/dist/bootstrap/setup-plan.d.ts +17 -3
- package/dist/bootstrap/setup-plan.js +39 -3
- package/dist/bootstrap/setup-state.d.ts +3 -1
- package/dist/bootstrap/setup-state.js +2 -0
- package/dist/cli/agent-api.js +67 -2
- package/dist/cli/auth.js +18 -1
- package/dist/cli/setup.js +12 -1
- package/dist/domain/issue.d.ts +34 -0
- package/dist/domain/write.d.ts +23 -0
- package/dist/mcp/tools/jira-context.tool.js +5 -1
- package/dist/mcp/tools/jira-full.tool.js +1 -1
- package/dist/mcp/tools/jira-search.tool.js +3 -1
- package/dist/policy/write-policy.d.ts +15 -0
- package/dist/policy/write-policy.js +19 -0
- package/dist/ports/credentials.port.d.ts +7 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -74,10 +74,10 @@ auth login Store Jira credentials in this user's OS secret store
|
|
|
74
74
|
runtime Show or change which JAM build this machine runs
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
Written out, that is `npx --yes @jam-mcp/launcher@1.4.
|
|
77
|
+
Written out, that is `npx --yes @jam-mcp/launcher@1.4.5 doctor`, or just `jam
|
|
78
78
|
doctor` if you took the launcher's optional global install. Starting from
|
|
79
79
|
nothing — no install, no runtime chosen yet — use
|
|
80
|
-
`npx --yes @jam-mcp/bootstrap@1.4.
|
|
80
|
+
`npx --yes @jam-mcp/bootstrap@1.4.5 init` instead.
|
|
81
81
|
|
|
82
82
|
Credentials come from the process environment or this user's OS secret store —
|
|
83
83
|
never from a repository file — and never appear in logs, telemetry, or tool
|
|
@@ -74,6 +74,15 @@ export class CompositeCredentialProvider {
|
|
|
74
74
|
description.baseUrl = values.JIRA_BASE_URL;
|
|
75
75
|
if (values.JIRA_EMAIL)
|
|
76
76
|
description.email = values.JIRA_EMAIL;
|
|
77
|
+
// Per-field provenance, so "mixed" can be read rather than guessed at.
|
|
78
|
+
const sources = {};
|
|
79
|
+
for (const field of ["JIRA_BASE_URL", "JIRA_EMAIL", "JIRA_API_TOKEN"]) {
|
|
80
|
+
const from = sourceByKey[field];
|
|
81
|
+
if (from)
|
|
82
|
+
sources[field] = from;
|
|
83
|
+
}
|
|
84
|
+
if (Object.keys(sources).length > 0)
|
|
85
|
+
description.sources = sources;
|
|
77
86
|
return description;
|
|
78
87
|
}
|
|
79
88
|
}
|
|
@@ -22,6 +22,15 @@ export function mapIssueWithMeta(raw, config) {
|
|
|
22
22
|
customFields: mapCustomFields(f, config),
|
|
23
23
|
comments: [],
|
|
24
24
|
};
|
|
25
|
+
// Jira returns `id` as a property of the issue resource, not as a field, so
|
|
26
|
+
// it arrives whatever the field list says and costs nothing to keep. Set
|
|
27
|
+
// only when Jira sent one: an empty string would read as an identity that
|
|
28
|
+
// was looked at and found blank.
|
|
29
|
+
if (raw.id)
|
|
30
|
+
issue.issueId = raw.id;
|
|
31
|
+
const statusCategory = category(f["status"]);
|
|
32
|
+
if (statusCategory)
|
|
33
|
+
issue.statusCategory = statusCategory;
|
|
25
34
|
const assignee = user(f["assignee"]);
|
|
26
35
|
if (assignee)
|
|
27
36
|
issue.assignee = assignee;
|
|
@@ -123,12 +132,17 @@ export function normalizeFieldValue(value) {
|
|
|
123
132
|
}
|
|
124
133
|
function issueRef(raw) {
|
|
125
134
|
const ref = { key: raw.key ?? "" };
|
|
135
|
+
if (raw.id)
|
|
136
|
+
ref.issueId = raw.id;
|
|
126
137
|
const summary = raw.fields?.summary;
|
|
127
138
|
if (summary)
|
|
128
139
|
ref.summary = summary;
|
|
129
140
|
const status = raw.fields?.status?.name;
|
|
130
141
|
if (status)
|
|
131
142
|
ref.status = status;
|
|
143
|
+
const statusCategory = raw.fields?.status?.statusCategory?.key;
|
|
144
|
+
if (statusCategory)
|
|
145
|
+
ref.statusCategory = statusCategory;
|
|
132
146
|
return ref;
|
|
133
147
|
}
|
|
134
148
|
function str(v) {
|
|
@@ -137,6 +151,17 @@ function str(v) {
|
|
|
137
151
|
function named(v) {
|
|
138
152
|
return v?.name;
|
|
139
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Jira's status category key, or nothing.
|
|
156
|
+
*
|
|
157
|
+
* Nothing, specifically, rather than a guess: the alternative is matching
|
|
158
|
+
* `status.name` against a list of words that mean "done", which is wrong in
|
|
159
|
+
* every language a project is not configured in and wrong in English the
|
|
160
|
+
* moment someone renames a status.
|
|
161
|
+
*/
|
|
162
|
+
function category(v) {
|
|
163
|
+
return v?.statusCategory?.key;
|
|
164
|
+
}
|
|
140
165
|
function user(v) {
|
|
141
166
|
const u = v;
|
|
142
167
|
return u?.displayName ?? u?.emailAddress ?? undefined;
|
|
@@ -26,11 +26,12 @@ export async function applyCreateIssue(deps, plan) {
|
|
|
26
26
|
}
|
|
27
27
|
await revalidateSchema(deps, plan);
|
|
28
28
|
const created = await create(deps, plan);
|
|
29
|
-
const after = await verify(deps, plan, created
|
|
29
|
+
const { observed: after, issueId } = await verify(deps, plan, created);
|
|
30
30
|
deps.writePlans.consume(plan.planId);
|
|
31
31
|
return {
|
|
32
32
|
status: "applied",
|
|
33
33
|
issue: created.key,
|
|
34
|
+
issueId,
|
|
34
35
|
operation: plan.operation,
|
|
35
36
|
before: plan.before,
|
|
36
37
|
after,
|
|
@@ -118,7 +119,8 @@ function isAmbiguous(err) {
|
|
|
118
119
|
* a project's automation adds - and none of that was requested, so requiring
|
|
119
120
|
* it to match something would be inventing an expectation nobody stated.
|
|
120
121
|
*/
|
|
121
|
-
async function verify(deps, plan,
|
|
122
|
+
async function verify(deps, plan, created) {
|
|
123
|
+
const issueKey = created.key;
|
|
122
124
|
// Where the issue landed is part of what was intended. The workspace binding
|
|
123
125
|
// is the whole of JAM's write scope, so a key from another project coming
|
|
124
126
|
// back from a create is the one outcome that must never be reported as the
|
|
@@ -127,7 +129,15 @@ async function verify(deps, plan, issueKey) {
|
|
|
127
129
|
if (createdProject !== plan.projectKey) {
|
|
128
130
|
throw verificationFailed(plan, issueKey, { project: plan.projectKey }, { project: createdProject ?? issueKey });
|
|
129
131
|
}
|
|
130
|
-
const { issue } = await readIssue(deps, issueKey);
|
|
132
|
+
const { issue, issueId } = await readIssue(deps, issueKey);
|
|
133
|
+
// Jira named both the id and the key when it accepted the create; the
|
|
134
|
+
// read-back names them again. They have to agree - a key that already
|
|
135
|
+
// resolves to a different issue than the one just created is the one case
|
|
136
|
+
// where reporting the created key would point whoever reads the receipt at
|
|
137
|
+
// somebody else's issue.
|
|
138
|
+
if (issueId !== created.id) {
|
|
139
|
+
throw verificationFailed(plan, issueKey, { issueId: created.id }, { issueId });
|
|
140
|
+
}
|
|
131
141
|
const observed = {};
|
|
132
142
|
for (const field of Object.keys(plan.intendedAfter)) {
|
|
133
143
|
observed[field] = observedValue(issue, field);
|
|
@@ -137,7 +147,7 @@ async function verify(deps, plan, issueKey) {
|
|
|
137
147
|
throw verificationFailed(plan, issueKey, plan.intendedAfter, observed);
|
|
138
148
|
}
|
|
139
149
|
}
|
|
140
|
-
return observed;
|
|
150
|
+
return { observed, issueId };
|
|
141
151
|
}
|
|
142
152
|
/**
|
|
143
153
|
* The created issue's value for one requested field.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { JamError, toJamError } from "../domain/errors.js";
|
|
2
2
|
import { readModeAfterWrite } from "../policy/consistency-policy.js";
|
|
3
3
|
import { assertAssignable } from "../policy/assignee-policy.js";
|
|
4
|
-
import { assertUnchanged } from "../policy/write-policy.js";
|
|
4
|
+
import { assertSameIssue, assertUnchanged } from "../policy/write-policy.js";
|
|
5
5
|
import { applyCreateIssue } from "./apply-create-issue.js";
|
|
6
6
|
import { readIssue } from "./plan-write.js";
|
|
7
7
|
/**
|
|
@@ -36,6 +36,10 @@ export async function applyWritePlan(deps, request) {
|
|
|
36
36
|
if (plan.kind === "create-issue")
|
|
37
37
|
return applyCreateIssue(deps, plan);
|
|
38
38
|
const current = await readIssue(deps, plan.issueKey);
|
|
39
|
+
// Identity before revision: if the key now names a different issue, its
|
|
40
|
+
// `updated` timestamp is a fact about something nobody planned to change,
|
|
41
|
+
// and comparing it would be answering the wrong question.
|
|
42
|
+
assertSameIssue(plan.issueKey, plan.issueId, current.issueId);
|
|
39
43
|
assertUnchanged(plan.issueKey, plan.baseUpdated, current.issue.updated);
|
|
40
44
|
// Whatever the plan depends on that the revision check cannot see, checked
|
|
41
45
|
// again here. For an assignment that is the target's permission to hold this
|
|
@@ -48,6 +52,7 @@ export async function applyWritePlan(deps, request) {
|
|
|
48
52
|
return {
|
|
49
53
|
status: "applied",
|
|
50
54
|
issue: plan.issueKey,
|
|
55
|
+
issueId: plan.issueId,
|
|
51
56
|
operation: plan.operation,
|
|
52
57
|
before: plan.before,
|
|
53
58
|
after,
|
|
@@ -126,6 +131,10 @@ function isAmbiguous(err) {
|
|
|
126
131
|
async function verify(deps, plan) {
|
|
127
132
|
const snapshot = await readIssue(deps, plan.issueKey);
|
|
128
133
|
const issue = snapshot.issue;
|
|
134
|
+
// The same identity question again, for the read that produces the evidence.
|
|
135
|
+
// Confirming the intended value on an issue the key has since come to name
|
|
136
|
+
// would be reporting somebody else's state as proof of our write.
|
|
137
|
+
assertSameIssue(plan.issueKey, plan.issueId, snapshot.issueId);
|
|
129
138
|
if (plan.mutation.kind === "assignee") {
|
|
130
139
|
// On the accountId, never on the display name. Two people can share a
|
|
131
140
|
// name, so a name comparison would accept the wrong person's assignment as
|
|
@@ -38,6 +38,16 @@ export declare function planWrite(deps: JamDeps, request: PlanWriteRequest): Pro
|
|
|
38
38
|
*/
|
|
39
39
|
export type IssueSnapshot = {
|
|
40
40
|
issue: FullIssueContext;
|
|
41
|
+
/**
|
|
42
|
+
* The issue's canonical Jira id, carried separately because the write plane
|
|
43
|
+
* requires it and the read shape only offers it.
|
|
44
|
+
*
|
|
45
|
+
* A key is a locator: Jira can move one between issues, and an integration
|
|
46
|
+
* that recorded a key months ago is holding a string, not a target. Every
|
|
47
|
+
* write is pinned to this instead - planned against it, re-checked against
|
|
48
|
+
* it before the mutation, and confirmed against it afterwards.
|
|
49
|
+
*/
|
|
50
|
+
issueId: string;
|
|
41
51
|
/** Identity of the current assignee, which `issue.assignee` cannot supply. */
|
|
42
52
|
assigneeAccountId?: string;
|
|
43
53
|
};
|
|
@@ -38,6 +38,7 @@ export async function planWrite(deps, request) {
|
|
|
38
38
|
const plan = deps.writePlans.create({
|
|
39
39
|
kind: "existing-issue",
|
|
40
40
|
issueKey,
|
|
41
|
+
issueId: snapshot.issueId,
|
|
41
42
|
projectKey,
|
|
42
43
|
operation,
|
|
43
44
|
before,
|
|
@@ -55,6 +56,7 @@ export async function planWrite(deps, request) {
|
|
|
55
56
|
status: "planned",
|
|
56
57
|
planId: plan.planId,
|
|
57
58
|
issue: plan.issueKey,
|
|
59
|
+
issueId: plan.issueId,
|
|
58
60
|
operation: plan.operation,
|
|
59
61
|
before: plan.before,
|
|
60
62
|
intendedAfter: plan.intendedAfter,
|
|
@@ -86,7 +88,20 @@ export async function readIssue(deps, issueKey) {
|
|
|
86
88
|
if (!found) {
|
|
87
89
|
throw new JamError("ISSUE_NOT_FOUND", `Jira has no issue ${issueKey}, or it is not visible to this account.`, { issueKey });
|
|
88
90
|
}
|
|
89
|
-
|
|
91
|
+
// Jira answered with an issue but named no canonical id. That is not a
|
|
92
|
+
// resolution JAM can build a write on, and it is refused here - at the one
|
|
93
|
+
// read every write goes through - rather than by filling the field with an
|
|
94
|
+
// empty string and carrying a fake identity into a plan. Reads are
|
|
95
|
+
// unaffected: a list of issues is not invalidated because one entry arrived
|
|
96
|
+
// thin, and only the write plane treats identity as proof.
|
|
97
|
+
if (!found.issueId) {
|
|
98
|
+
throw new JamError("PARTIAL_RESULT", `Jira returned ${issueKey} without a canonical issue id, so JAM cannot confirm which issue this key currently names. Read the issue in Jira before changing it.`, { issueKey });
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
issue: found,
|
|
102
|
+
issueId: found.issueId,
|
|
103
|
+
...(assigneeAccountId ? { assigneeAccountId } : {}),
|
|
104
|
+
};
|
|
90
105
|
}
|
|
91
106
|
/**
|
|
92
107
|
* The issue an existing-issue operation names, or a refusal that says why.
|
|
@@ -77,10 +77,19 @@ export async function searchIssues(deps, input) {
|
|
|
77
77
|
}
|
|
78
78
|
/** Project down to lite fields so heavy data cannot leak through this path. */
|
|
79
79
|
export function toSummary(issue) {
|
|
80
|
+
// Identity and status semantics ride along at every level, next to the
|
|
81
|
+
// fields they qualify. Both are already in the payload this projection is
|
|
82
|
+
// narrowing, so carrying them costs nothing - and dropping them would make
|
|
83
|
+
// the cheapest read the one an agent cannot safely act on: a key with no
|
|
84
|
+
// identity behind it, and a status name whose meaning it would have to
|
|
85
|
+
// guess. Spread rather than assigned afterwards so the JSON an agent reads
|
|
86
|
+
// puts each beside its subject.
|
|
80
87
|
const summary = {
|
|
81
88
|
key: issue.key,
|
|
89
|
+
...(issue.issueId ? { issueId: issue.issueId } : {}),
|
|
82
90
|
summary: issue.summary,
|
|
83
91
|
status: issue.status,
|
|
92
|
+
...(issue.statusCategory ? { statusCategory: issue.statusCategory } : {}),
|
|
84
93
|
updated: issue.updated,
|
|
85
94
|
labels: issue.labels,
|
|
86
95
|
components: issue.components,
|
|
@@ -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.4.
|
|
16
|
+
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.4.5", "serve"];
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* Recognise wiring from before the launcher existed: a hard-coded `node` path
|
|
@@ -3,13 +3,23 @@ import type { MigrationTarget } from "./migration-target.js";
|
|
|
3
3
|
import { type BootstrapSource } from "./project-config-bootstrapper.js";
|
|
4
4
|
import type { SetupState } from "./setup-state.js";
|
|
5
5
|
export type SetupStatus = "already_configured" | "ready_to_apply" | "user_action_required";
|
|
6
|
-
export type SetupCode = "JAM_PROJECT_SELECTION_REQUIRED" | "JAM_BINDINGS_UNREADABLE" | "JAM_AUTH_REQUIRED" | "JAM_RUNTIME_CONFIG_MISSING" | "JAM_PROJECT_CONFIG_INVALID" | "JAM_MCP_CONFIG_UNREADABLE" | "JAM_MIGRATION_TARGET_UNAVAILABLE";
|
|
6
|
+
export type SetupCode = "JAM_PROJECT_SELECTION_REQUIRED" | "JAM_BINDINGS_UNREADABLE" | "JAM_AUTH_REQUIRED" | "JAM_RUNTIME_CONFIG_MISSING" | "JAM_PROJECT_CONFIG_INVALID" | "JAM_MCP_CONFIG_UNREADABLE" | "JAM_MIGRATION_TARGET_UNAVAILABLE" | "JAM_PROJECT_KEY_CONFLICT";
|
|
7
|
+
/**
|
|
8
|
+
* Where a project key came from. `repository` is the team's committed
|
|
9
|
+
* `.jira-agent/project.yaml`; the rest are this user's own settings, in the
|
|
10
|
+
* precedence order `decideProjectKey` applies.
|
|
11
|
+
*/
|
|
12
|
+
export type KeySource = BootstrapSource | "repository";
|
|
13
|
+
export type KeyOrigin = {
|
|
14
|
+
key: string;
|
|
15
|
+
source: KeySource;
|
|
16
|
+
};
|
|
7
17
|
export type SetupChange = {
|
|
8
18
|
type: "create";
|
|
9
19
|
target: "project-config";
|
|
10
20
|
path: string;
|
|
11
21
|
key: string;
|
|
12
|
-
keySource:
|
|
22
|
+
keySource: KeySource;
|
|
13
23
|
} | {
|
|
14
24
|
type: "create";
|
|
15
25
|
target: "mcp-config";
|
|
@@ -30,7 +40,7 @@ export type SetupChange = {
|
|
|
30
40
|
path: string;
|
|
31
41
|
workspaceId: string;
|
|
32
42
|
key: string;
|
|
33
|
-
keySource:
|
|
43
|
+
keySource: KeySource;
|
|
34
44
|
/** Present on a rebind, so the preview shows what is being replaced. */
|
|
35
45
|
previousKey?: string;
|
|
36
46
|
} | {
|
|
@@ -95,7 +105,11 @@ export type SetupPlan = {
|
|
|
95
105
|
project?: {
|
|
96
106
|
root: string;
|
|
97
107
|
key?: string;
|
|
108
|
+
keySource?: KeySource;
|
|
98
109
|
};
|
|
110
|
+
/** On JAM_PROJECT_KEY_CONFLICT: what was asked for, and what already stands. */
|
|
111
|
+
requested?: KeyOrigin;
|
|
112
|
+
existing?: KeyOrigin;
|
|
99
113
|
};
|
|
100
114
|
export type PlanOptions = {
|
|
101
115
|
/**
|
|
@@ -52,7 +52,23 @@ export function computeSetupPlan(state, options = {}) {
|
|
|
52
52
|
project: { root: state.project.root },
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
// The repository's committed key is the team's answer. When a personal
|
|
56
|
+
// `--project` disagrees with it, neither side may win silently: overwriting
|
|
57
|
+
// the repository is not setup's call, and quietly using the repository key
|
|
58
|
+
// makes the flag a lie. Stop, and name both sources.
|
|
59
|
+
const conflict = keyConflict(state, options, key);
|
|
60
|
+
if (conflict) {
|
|
61
|
+
return {
|
|
62
|
+
status: "user_action_required",
|
|
63
|
+
code: "JAM_PROJECT_KEY_CONFLICT",
|
|
64
|
+
changes: [],
|
|
65
|
+
requiresUserAction: true,
|
|
66
|
+
requested: conflict.requested,
|
|
67
|
+
existing: conflict.existing,
|
|
68
|
+
project: { root: state.project.root, key: conflict.existing.key, keySource: conflict.existing.source },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const project = { root: state.project.root, key: key.key, keySource: key.source };
|
|
56
72
|
if (!shared) {
|
|
57
73
|
// Personal scope: the record of "this workspace is that Jira project"
|
|
58
74
|
// lives with the user, and nothing in the repository is touched.
|
|
@@ -207,9 +223,11 @@ function planBindingChange(state, key) {
|
|
|
207
223
|
}
|
|
208
224
|
function resolveKey(state, options) {
|
|
209
225
|
// An existing project.yaml wins: setup must never silently repoint a project,
|
|
210
|
-
// and a personal note must never override what the team committed.
|
|
226
|
+
// and a personal note must never override what the team committed. It is
|
|
227
|
+
// labelled `repository` rather than `explicit` - a reader has to be able to
|
|
228
|
+
// tell the team's committed answer from what someone typed.
|
|
211
229
|
if (state.project.key)
|
|
212
|
-
return { key: state.project.key, source: "
|
|
230
|
+
return { key: state.project.key, source: "repository" };
|
|
213
231
|
const decideOptions = {};
|
|
214
232
|
if (options.explicitKey)
|
|
215
233
|
decideOptions.explicitKey = options.explicitKey;
|
|
@@ -221,6 +239,24 @@ function resolveKey(state, options) {
|
|
|
221
239
|
decideOptions.presetsPath = options.presetsPath;
|
|
222
240
|
return decideProjectKey(state.project.root, decideOptions);
|
|
223
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* A repository key and an explicit `--project` that disagree. Personal
|
|
244
|
+
* sources are not conflicts: explicit already beats them in decideProjectKey,
|
|
245
|
+
* and a stale binding is repaired rather than reported.
|
|
246
|
+
*/
|
|
247
|
+
function keyConflict(state, options, resolved) {
|
|
248
|
+
const explicit = options.explicitKey?.trim();
|
|
249
|
+
if (!explicit)
|
|
250
|
+
return undefined;
|
|
251
|
+
const repository = state.project.key;
|
|
252
|
+
if (!repository || repository === explicit)
|
|
253
|
+
return undefined;
|
|
254
|
+
void resolved;
|
|
255
|
+
return {
|
|
256
|
+
requested: { key: explicit, source: "explicit" },
|
|
257
|
+
existing: { key: repository, source: "repository" },
|
|
258
|
+
};
|
|
259
|
+
}
|
|
224
260
|
function planMcpChange(state, options) {
|
|
225
261
|
if (!state.mcp.exists) {
|
|
226
262
|
return { type: "create", target: "mcp-config", path: state.mcp.path };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type RuntimeMode } from "@jam-mcp/launcher";
|
|
2
|
-
import type { CredentialPort, CredentialSource } from "../ports/credentials.port.js";
|
|
2
|
+
import type { CredentialDescription, CredentialPort, CredentialSource } from "../ports/credentials.port.js";
|
|
3
3
|
import { type HostRunner, type HostState } from "./host-mcp.js";
|
|
4
4
|
import { type McpInspection } from "./mcp-config-merger.js";
|
|
5
5
|
import { type ProjectBinding } from "./project-bindings.js";
|
|
@@ -15,6 +15,8 @@ export type RuntimeState = {
|
|
|
15
15
|
export type CredentialState = {
|
|
16
16
|
present: boolean;
|
|
17
17
|
source: CredentialSource;
|
|
18
|
+
/** Which source supplied each field. Names only - never a value. */
|
|
19
|
+
sources?: CredentialDescription["sources"];
|
|
18
20
|
baseUrl?: string;
|
|
19
21
|
email?: string;
|
|
20
22
|
};
|
|
@@ -61,6 +61,8 @@ function detectCredentials(credentials) {
|
|
|
61
61
|
source: described.source,
|
|
62
62
|
};
|
|
63
63
|
// Presence and origin only - the token value never enters this snapshot.
|
|
64
|
+
if (described.sources)
|
|
65
|
+
state.sources = described.sources;
|
|
64
66
|
if (described.baseUrl)
|
|
65
67
|
state.baseUrl = described.baseUrl;
|
|
66
68
|
if (described.email)
|
package/dist/cli/agent-api.js
CHANGED
|
@@ -61,7 +61,8 @@ export async function setupApplyCommand(options = {}) {
|
|
|
61
61
|
}
|
|
62
62
|
if (plan.code === "JAM_PROJECT_CONFIG_INVALID" ||
|
|
63
63
|
plan.code === "JAM_MCP_CONFIG_UNREADABLE" ||
|
|
64
|
-
plan.code === "JAM_BINDINGS_UNREADABLE"
|
|
64
|
+
plan.code === "JAM_BINDINGS_UNREADABLE" ||
|
|
65
|
+
plan.code === "JAM_PROJECT_KEY_CONFLICT") {
|
|
65
66
|
emitJson({ ...plan, changesApplied: false });
|
|
66
67
|
return 1;
|
|
67
68
|
}
|
|
@@ -102,7 +103,8 @@ export async function setupAgentCommand(options = {}) {
|
|
|
102
103
|
}
|
|
103
104
|
if (plan.code === "JAM_PROJECT_CONFIG_INVALID" ||
|
|
104
105
|
plan.code === "JAM_MCP_CONFIG_UNREADABLE" ||
|
|
105
|
-
plan.code === "JAM_BINDINGS_UNREADABLE"
|
|
106
|
+
plan.code === "JAM_BINDINGS_UNREADABLE" ||
|
|
107
|
+
plan.code === "JAM_PROJECT_KEY_CONFLICT") {
|
|
106
108
|
emitJson({ ...plan, changesApplied: false });
|
|
107
109
|
return 1;
|
|
108
110
|
}
|
|
@@ -147,10 +149,71 @@ export async function doctorJsonCommand(options = {}) {
|
|
|
147
149
|
...(health.error ? { error: health.error } : {}),
|
|
148
150
|
project: { root: state.project.root, ...(state.project.key ? { key: state.project.key } : {}) },
|
|
149
151
|
axes,
|
|
152
|
+
diagnosis: diagnose(state, axes, health),
|
|
150
153
|
checks: health.checks,
|
|
151
154
|
});
|
|
152
155
|
return passed ? 0 : 1;
|
|
153
156
|
}
|
|
157
|
+
function diagnose(state, axes, health) {
|
|
158
|
+
const check = (name) => health.checks.find((c) => c.name === name);
|
|
159
|
+
const fromCheck = (name, code) => {
|
|
160
|
+
const found = check(name);
|
|
161
|
+
if (!found)
|
|
162
|
+
return { state: "UNCHECKED" };
|
|
163
|
+
return found.ok
|
|
164
|
+
? { state: "OK", ...(found.detail ? { detail: found.detail } : {}) }
|
|
165
|
+
: { state: "FAILED", code, ...(found.detail ? { detail: found.detail } : {}) };
|
|
166
|
+
};
|
|
167
|
+
// Credentials: this axis answers "are all three fields resolvable, and from
|
|
168
|
+
// where" - the snapshot knows that. Whether Jira accepts them is a different
|
|
169
|
+
// question with its own axis below, and conflating the two is what made a
|
|
170
|
+
// working "mixed" setup read as broken. "mixed" is never a failure here.
|
|
171
|
+
const credentialCheck = check("Credentials present");
|
|
172
|
+
const mixed = state.credentials.source === "mixed";
|
|
173
|
+
const credentials = !state.credentials.present
|
|
174
|
+
? {
|
|
175
|
+
state: "FAILED",
|
|
176
|
+
code: "JAM_AUTH_REQUIRED",
|
|
177
|
+
...(credentialCheck?.detail ? { detail: credentialCheck.detail } : {}),
|
|
178
|
+
}
|
|
179
|
+
: mixed
|
|
180
|
+
? { state: "WARNING", detail: `fields come from more than one source: ${describeSources(state)}` }
|
|
181
|
+
: { state: "OK", detail: `source ${state.credentials.source}` };
|
|
182
|
+
return {
|
|
183
|
+
credentials,
|
|
184
|
+
projectBinding: state.project.key
|
|
185
|
+
? { state: "OK", detail: `key ${state.project.key}` }
|
|
186
|
+
: { state: "FAILED", code: "JAM_PROJECT_SELECTION_REQUIRED", detail: "no project key for this workspace" },
|
|
187
|
+
runtime: axes.package === "PACKAGE_READY"
|
|
188
|
+
? { state: "OK", ...(axes.packageVersion ? { detail: axes.packageVersion } : {}) }
|
|
189
|
+
: { state: "FAILED", code: "JAM_RUNTIME_CONFIG_MISSING", ...(state.runtime.error ? { detail: state.runtime.error } : {}) },
|
|
190
|
+
registration: axes.registration === "OK"
|
|
191
|
+
? { state: "OK", ...(axes.registeredVersion ? { detail: axes.registeredVersion } : {}) }
|
|
192
|
+
: axes.registration === "UNREGISTERED"
|
|
193
|
+
? { state: "UNCHECKED", detail: "no host has a jam entry for this user" }
|
|
194
|
+
: { state: "FAILED", code: axes.registration, ...(axes.detail ? { detail: axes.detail } : {}) },
|
|
195
|
+
liveToolset: axes.live === "OK"
|
|
196
|
+
? { state: "OK" }
|
|
197
|
+
: axes.live === "UNCHECKED"
|
|
198
|
+
? { state: "UNCHECKED", ...(axes.detail ? { detail: axes.detail } : {}) }
|
|
199
|
+
: {
|
|
200
|
+
state: "FAILED",
|
|
201
|
+
code: axes.live,
|
|
202
|
+
...(axes.missingTools ? { detail: `missing: ${axes.missingTools.join(", ")}` } : {}),
|
|
203
|
+
},
|
|
204
|
+
jiraAuthentication: fromCheck("Jira authentication", "JAM_JIRA_AUTHENTICATION_FAILED"),
|
|
205
|
+
jiraProjectAccess: fromCheck(health.checks.find((c) => c.name.startsWith("JQL search"))?.name ?? "JQL search", "JAM_JIRA_PROJECT_ACCESS_FAILED"),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/** Field-to-source names only. A credential value never appears here. */
|
|
209
|
+
function describeSources(state) {
|
|
210
|
+
const sources = state.credentials.sources;
|
|
211
|
+
if (!sources)
|
|
212
|
+
return "unknown";
|
|
213
|
+
return Object.entries(sources)
|
|
214
|
+
.map(([field, from]) => `${field}=${from}`)
|
|
215
|
+
.join(", ");
|
|
216
|
+
}
|
|
154
217
|
async function inspectAxes(state, options) {
|
|
155
218
|
const packageVersion = state.runtime.version;
|
|
156
219
|
const axes = {
|
|
@@ -207,6 +270,8 @@ export function authStatusCommand(options = {}) {
|
|
|
207
270
|
status: credentials.present ? "configured" : "not_configured",
|
|
208
271
|
...(credentials.present ? {} : { code: "JAM_AUTH_REQUIRED" }),
|
|
209
272
|
source: credentials.source,
|
|
273
|
+
// Which field came from where. Names only - a value never appears.
|
|
274
|
+
...(credentials.sources ? { sources: credentials.sources } : {}),
|
|
210
275
|
...(credentials.email ? { email: credentials.email } : {}),
|
|
211
276
|
...(credentials.baseUrl ? { baseUrl: credentials.baseUrl } : {}),
|
|
212
277
|
});
|
package/dist/cli/auth.js
CHANGED
|
@@ -137,7 +137,8 @@ export function authLogoutCommand(options = {}) {
|
|
|
137
137
|
* credential unreachable, and the resulting split shows up as "mixed".
|
|
138
138
|
*/
|
|
139
139
|
function reportOverride(ui, port) {
|
|
140
|
-
const
|
|
140
|
+
const described = port.describe();
|
|
141
|
+
const source = described.source;
|
|
141
142
|
if (source === "secret-store")
|
|
142
143
|
return;
|
|
143
144
|
if (source === "mixed") {
|
|
@@ -147,6 +148,8 @@ function reportOverride(ui, port) {
|
|
|
147
148
|
ui.warn("Current JIRA_* environment variables override the stored credentials");
|
|
148
149
|
}
|
|
149
150
|
ui.line(` Effective source: ${source}`);
|
|
151
|
+
for (const line of fieldSourceLines(described))
|
|
152
|
+
ui.line(line);
|
|
150
153
|
ui.line(" Unset them to use what was just stored.");
|
|
151
154
|
}
|
|
152
155
|
/** After a logout, say plainly whether anything still authenticates JAM. */
|
|
@@ -161,6 +164,8 @@ function reportRemaining(ui, port) {
|
|
|
161
164
|
? "Jira credentials still resolve from outside the secret store"
|
|
162
165
|
: "Part of a Jira credential still resolves from outside the secret store");
|
|
163
166
|
ui.line(` Effective source: ${described.source}`);
|
|
167
|
+
for (const line of fieldSourceLines(described))
|
|
168
|
+
ui.line(line);
|
|
164
169
|
ui.line(" Unset JIRA_BASE_URL, JIRA_EMAIL and JIRA_API_TOKEN to finish logging out.");
|
|
165
170
|
}
|
|
166
171
|
/** undefined when Jira accepted the credentials; otherwise the reason. */
|
|
@@ -200,3 +205,15 @@ export function toJiraOrigin(input) {
|
|
|
200
205
|
}
|
|
201
206
|
return url.protocol === "http:" || url.protocol === "https:" ? url.origin : undefined;
|
|
202
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Which field came from where. "mixed" on its own tells a reader that
|
|
210
|
+
* something is split without telling them what, so they go looking - these
|
|
211
|
+
* lines answer it. Field names and source names only: a credential value is
|
|
212
|
+
* never printed.
|
|
213
|
+
*/
|
|
214
|
+
function fieldSourceLines(described) {
|
|
215
|
+
const sources = described.sources;
|
|
216
|
+
if (!sources)
|
|
217
|
+
return [];
|
|
218
|
+
return Object.entries(sources).map(([field, from]) => ` ${field}: ${from}`);
|
|
219
|
+
}
|
package/dist/cli/setup.js
CHANGED
|
@@ -44,7 +44,8 @@ export async function setup(options = {}) {
|
|
|
44
44
|
}
|
|
45
45
|
if (plan.code === "JAM_PROJECT_CONFIG_INVALID" ||
|
|
46
46
|
plan.code === "JAM_MCP_CONFIG_UNREADABLE" ||
|
|
47
|
-
plan.code === "JAM_BINDINGS_UNREADABLE"
|
|
47
|
+
plan.code === "JAM_BINDINGS_UNREADABLE" ||
|
|
48
|
+
plan.code === "JAM_PROJECT_KEY_CONFLICT") {
|
|
48
49
|
line(`[FAIL] ${describeBlockingCode(plan)}`);
|
|
49
50
|
return 1;
|
|
50
51
|
}
|
|
@@ -133,6 +134,16 @@ function reportApplied(applied, plan) {
|
|
|
133
134
|
void plan;
|
|
134
135
|
}
|
|
135
136
|
function describeBlockingCode(plan) {
|
|
137
|
+
if (plan.code === "JAM_PROJECT_KEY_CONFLICT") {
|
|
138
|
+
// Both sides named, and no suggestion to delete the repository's file -
|
|
139
|
+
// which project this repository belongs to is the team's decision.
|
|
140
|
+
return [
|
|
141
|
+
`This repository declares ${plan.existing?.key} in .jira-agent/project.yaml,`,
|
|
142
|
+
`but --project asked for ${plan.requested?.key}. JAM will not overwrite the`,
|
|
143
|
+
"committed key. Either drop --project to use the repository's, or change",
|
|
144
|
+
"the repository's project.yaml with the team and re-run.",
|
|
145
|
+
].join(" ");
|
|
146
|
+
}
|
|
136
147
|
if (plan.code === "JAM_PROJECT_CONFIG_INVALID") {
|
|
137
148
|
return "The project's .jira-agent/project.yaml could not be parsed. Fix it and re-run.";
|
|
138
149
|
}
|
package/dist/domain/issue.d.ts
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A reference to a Jira issue.
|
|
3
|
+
*
|
|
4
|
+
* `key` is what a person types, a branch name carries and an integration links
|
|
5
|
+
* on - and it is not an identity. Jira mints keys per project and a key can be
|
|
6
|
+
* moved between issues, so the same string can name a different issue later.
|
|
7
|
+
* `issueId` is the identity: the immutable id Jira assigns once and never
|
|
8
|
+
* reuses.
|
|
9
|
+
*
|
|
10
|
+
* `issueId` is optional because JAM will not invent one. Jira supplies it on
|
|
11
|
+
* every issue resource and on the nested references it embeds, but a payload
|
|
12
|
+
* that omits it leaves the field absent rather than empty - "not returned" and
|
|
13
|
+
* "returned blank" are different facts, and only one of them is true.
|
|
14
|
+
*/
|
|
1
15
|
export type IssueRef = {
|
|
2
16
|
key: string;
|
|
17
|
+
issueId?: string;
|
|
3
18
|
summary?: string;
|
|
4
19
|
status?: string;
|
|
20
|
+
/** See IssueSummary.statusCategory. */
|
|
21
|
+
statusCategory?: string;
|
|
5
22
|
};
|
|
6
23
|
/**
|
|
7
24
|
* SEARCH level. Deliberately excludes description/comments/attachments/changelog
|
|
@@ -9,8 +26,25 @@ export type IssueRef = {
|
|
|
9
26
|
*/
|
|
10
27
|
export type IssueSummary = {
|
|
11
28
|
key: string;
|
|
29
|
+
/** Jira's immutable issue id. See IssueRef. */
|
|
30
|
+
issueId?: string;
|
|
12
31
|
summary: string;
|
|
13
32
|
status: string;
|
|
33
|
+
/**
|
|
34
|
+
* Jira's own machine-readable status category key, as Jira publishes it -
|
|
35
|
+
* currently `new`, `indeterminate` or `done`, plus `undefined` for a status
|
|
36
|
+
* in no category.
|
|
37
|
+
*
|
|
38
|
+
* Passed through, never derived. `status` is a workflow-defined, localized
|
|
39
|
+
* name: a project can call a category-`done` status "Shipped", "완료" or
|
|
40
|
+
* "Won't Fix", and matching those strings is how an agent decides an issue
|
|
41
|
+
* is finished when it is not. This is the field to read instead, and JAM
|
|
42
|
+
* neither renames Jira's values nor turns them into a verdict of its own.
|
|
43
|
+
*
|
|
44
|
+
* Absent when Jira returned a status without a category. Absent is absent -
|
|
45
|
+
* it is not `new`.
|
|
46
|
+
*/
|
|
47
|
+
statusCategory?: string;
|
|
14
48
|
assignee?: string;
|
|
15
49
|
priority?: string;
|
|
16
50
|
updated: string;
|
package/dist/domain/write.d.ts
CHANGED
|
@@ -172,6 +172,16 @@ type WritePlanCommon = {
|
|
|
172
172
|
export type ExistingIssueWritePlan = WritePlanCommon & {
|
|
173
173
|
kind: "existing-issue";
|
|
174
174
|
issueKey: string;
|
|
175
|
+
/**
|
|
176
|
+
* The canonical Jira id of the issue this plan was made against.
|
|
177
|
+
*
|
|
178
|
+
* `issueKey` says where to look; this says what was found there. They are
|
|
179
|
+
* not the same guarantee: a key is a locator Jira can move between issues,
|
|
180
|
+
* so re-reading the key at apply time can return a different issue than the
|
|
181
|
+
* one the plan described. Comparing this is what turns "the key still
|
|
182
|
+
* resolves" into "it still resolves to the issue that was planned".
|
|
183
|
+
*/
|
|
184
|
+
issueId: string;
|
|
175
185
|
operation: ExistingIssueOperation;
|
|
176
186
|
baseUpdated: string;
|
|
177
187
|
/**
|
|
@@ -239,6 +249,11 @@ export type WritePlanReceipt = {
|
|
|
239
249
|
* yet - a placeholder key here would be a claim JAM cannot make.
|
|
240
250
|
*/
|
|
241
251
|
issue?: string;
|
|
252
|
+
/**
|
|
253
|
+
* The canonical Jira id of that issue. Absent for `issue.create` for the
|
|
254
|
+
* same reason `issue` is: Jira mints both, and it has not been asked yet.
|
|
255
|
+
*/
|
|
256
|
+
issueId?: string;
|
|
242
257
|
/** The project a new issue would be created in. Present for `issue.create`. */
|
|
243
258
|
project?: string;
|
|
244
259
|
/** How the result of applying this plan will be confirmed. */
|
|
@@ -253,6 +268,14 @@ export type WriteApplyReceipt = {
|
|
|
253
268
|
status: "applied";
|
|
254
269
|
/** For `issue.create`, the key Jira minted - known only after applying. */
|
|
255
270
|
issue: string;
|
|
271
|
+
/**
|
|
272
|
+
* The canonical Jira id of the issue that was written, read back from Jira.
|
|
273
|
+
*
|
|
274
|
+
* What to record if this write is going to be referred to later. The key is
|
|
275
|
+
* how a person and an integration will find the issue; this is what says the
|
|
276
|
+
* thing they find is the thing that was changed.
|
|
277
|
+
*/
|
|
278
|
+
issueId: string;
|
|
256
279
|
operation: WriteOperation;
|
|
257
280
|
before: Record<string, unknown>;
|
|
258
281
|
after: Record<string, unknown>;
|
|
@@ -9,7 +9,11 @@ Returns everything jira_search returns plus issue type, parent, subtasks, issue
|
|
|
9
9
|
|
|
10
10
|
This is the Jira-recorded evidence relevant to readiness, blockers, dependencies and priority - not a readiness verdict. blocksThisIssue reports how Jira words a link; an empty links array means Jira holds no visible link for you, not that nothing blocks the work. Repository and external sources are not evaluated.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Every issue carries issueId, Jira's immutable id, alongside key. The key is the current human- and integration-facing locator and Jira can move it to another issue; issueId is the identity. Record issueId when a reference has to survive. statusCategory is Jira's own machine-readable category for the status - read it instead of matching status text, which is workflow-defined and localized. Parent, subtasks and links carry issueId too, wherever Jira supplies one.
|
|
13
|
+
|
|
14
|
+
Pass every key you care about in one call; they are fetched in a single batched round trip. Check meta.complete and meta.missingKeys before drawing conclusions.
|
|
15
|
+
|
|
16
|
+
This is also how a Jira issue key is checked. Asking for an exact key and getting an issue back is a positive resolution: that key names that issue, right now. A key listed in meta.missingKeys resolved to nothing JAM can see - it may not exist, or it may not be visible to this account, and those are indistinguishable from here. Either way it is unusable, and it is NOT evidence that the number is free, unused or reservable. Never synthesize, increment, predict or reserve a Jira issue key; keys are minted by Jira.`;
|
|
13
17
|
export function registerJiraContext(server, deps) {
|
|
14
18
|
server.registerTool("jira_context", {
|
|
15
19
|
title: "Jira issue context (dependencies, blockers, readiness)",
|
|
@@ -5,7 +5,7 @@ const DESCRIPTION = `Get the complete record for one or more Jira issues, includ
|
|
|
5
5
|
|
|
6
6
|
Use for final judgements: was this agreed, is the contract settled, was it approved, can it be closed, what did the other team actually answer, what does this issue mean right now.
|
|
7
7
|
|
|
8
|
-
Returns everything jira_context returns plus description and every comment (normalized to plain text). This is the most expensive tool - prefer jira_search for listing and jira_context for readiness, and reach for this one when the answer must not be wrong.
|
|
8
|
+
Returns everything jira_context returns - issueId and statusCategory included - plus description and every comment (normalized to plain text). This is the most expensive tool - prefer jira_search for listing and jira_context for readiness, and reach for this one when the answer must not be wrong.
|
|
9
9
|
|
|
10
10
|
Ask for as few keys as possible: with several issues at once the output budget may drop the oldest comments. Always check meta.commentsComplete and meta.complete - if either is false, the thread you are reading is partial and a "yes, it is agreed" answer is not supported.
|
|
11
11
|
|
|
@@ -5,10 +5,12 @@ const DESCRIPTION = `Find Jira issues by JQL and get a lightweight list back.
|
|
|
5
5
|
|
|
6
6
|
Use for: discovery, listing, "what is open", "what is assigned to me", recent changes, picking candidate issues.
|
|
7
7
|
|
|
8
|
-
Returns key, summary, status, assignee, priority, updated, labels and components only. It deliberately does NOT return description, comments, attachments or links - that keeps listing cheap.
|
|
8
|
+
Returns key, issueId, summary, status, statusCategory, assignee, priority, updated, labels and components only. It deliberately does NOT return description, comments, attachments or links - that keeps listing cheap.
|
|
9
9
|
|
|
10
10
|
Because of that, a jira_search result is NOT complete issue context. Never conclude from it that something is agreed, approved, unblocked, or done. Follow up with jira_context (readiness, blockers, dependencies, priority) or jira_full (agreement, contract, approval, closure).
|
|
11
11
|
|
|
12
|
+
Every issue carries issueId, Jira's immutable id, alongside key. The key is the current human- and integration-facing locator and Jira can move it to another issue; issueId is the identity. Record issueId when a reference has to survive. statusCategory is Jira's own machine-readable category for the status - read it instead of matching status text, which is workflow-defined and localized.
|
|
13
|
+
|
|
12
14
|
Repository and external sources are not evaluated.
|
|
13
15
|
|
|
14
16
|
scope="preview" (default) returns the first page for interactive exploration. scope="complete" walks every page - use it whenever the answer depends on the total count or on seeing every match. Check meta.complete before treating the list as exhaustive.`;
|
|
@@ -31,6 +31,21 @@ export declare function projectKeyOf(issueKey: string): string | undefined;
|
|
|
31
31
|
* comment - must not be able to reach into another team's project through it.
|
|
32
32
|
*/
|
|
33
33
|
export declare function assertWriteScope(issueKey: string, configuredProject: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* The key still names the issue the plan was made against.
|
|
36
|
+
*
|
|
37
|
+
* A revision check answers "has this issue changed"; this answers the question
|
|
38
|
+
* underneath it - "is this the same issue at all". Jira keys are locators, not
|
|
39
|
+
* identities: one can be moved to another issue, and an integration holding
|
|
40
|
+
* the string would then be pointed somewhere nobody chose. Re-reading the key
|
|
41
|
+
* and finding a different canonical id means the plan describes an issue this
|
|
42
|
+
* key no longer names, and the answer is a new plan, not this write.
|
|
43
|
+
*
|
|
44
|
+
* JAM_WRITE_CONFLICT rather than a code of its own: the situation is the one
|
|
45
|
+
* an agent already knows how to handle - the ground moved, plan again against
|
|
46
|
+
* the current state - and a second code for it would only fragment that.
|
|
47
|
+
*/
|
|
48
|
+
export declare function assertSameIssue(issueKey: string, planned: string, observed: string): void;
|
|
34
49
|
export declare function assertOperationAllowed(operation: string): WriteOperation;
|
|
35
50
|
/**
|
|
36
51
|
* Narrow an already-allowed operation to one that acts on an existing issue.
|
|
@@ -48,6 +48,25 @@ export function assertWriteScope(issueKey, configuredProject) {
|
|
|
48
48
|
}
|
|
49
49
|
return project;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* The key still names the issue the plan was made against.
|
|
53
|
+
*
|
|
54
|
+
* A revision check answers "has this issue changed"; this answers the question
|
|
55
|
+
* underneath it - "is this the same issue at all". Jira keys are locators, not
|
|
56
|
+
* identities: one can be moved to another issue, and an integration holding
|
|
57
|
+
* the string would then be pointed somewhere nobody chose. Re-reading the key
|
|
58
|
+
* and finding a different canonical id means the plan describes an issue this
|
|
59
|
+
* key no longer names, and the answer is a new plan, not this write.
|
|
60
|
+
*
|
|
61
|
+
* JAM_WRITE_CONFLICT rather than a code of its own: the situation is the one
|
|
62
|
+
* an agent already knows how to handle - the ground moved, plan again against
|
|
63
|
+
* the current state - and a second code for it would only fragment that.
|
|
64
|
+
*/
|
|
65
|
+
export function assertSameIssue(issueKey, planned, observed) {
|
|
66
|
+
if (planned === observed)
|
|
67
|
+
return;
|
|
68
|
+
throw new JamError("JAM_WRITE_CONFLICT", `${issueKey} no longer names the issue this plan was made against (planned ${planned}, now ${observed}). Plan again against the issue the key names now.`, { issueKey, plannedIssueId: planned, observedIssueId: observed });
|
|
69
|
+
}
|
|
51
70
|
export function assertOperationAllowed(operation) {
|
|
52
71
|
if (!isWriteOperation(operation)) {
|
|
53
72
|
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", `"${operation}" is not a JAM write operation. Supported: ${WRITE_OPERATIONS.join(", ")}.`, { operation, supported: [...WRITE_OPERATIONS] });
|
|
@@ -14,6 +14,13 @@ export type CredentialDescription = {
|
|
|
14
14
|
email?: string;
|
|
15
15
|
hasToken: boolean;
|
|
16
16
|
source: CredentialSource;
|
|
17
|
+
/**
|
|
18
|
+
* Which source supplied each field. `source` alone says "mixed" without
|
|
19
|
+
* saying mixed how, which reads as a fault when it is a normal state - a
|
|
20
|
+
* base URL and email in the OS store with the token exported for one shell
|
|
21
|
+
* is a supported setup. Names only: no value ever appears here.
|
|
22
|
+
*/
|
|
23
|
+
sources?: Partial<Record<"JIRA_BASE_URL" | "JIRA_EMAIL" | "JIRA_API_TOKEN", Exclude<CredentialSource, "mixed" | "none">>>;
|
|
17
24
|
};
|
|
18
25
|
export interface CredentialPort {
|
|
19
26
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jam-mcp/server",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.5",
|
|
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.4.
|
|
44
|
+
"@jam-mcp/launcher": "1.4.5",
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
46
46
|
"yaml": "^2.9.0",
|
|
47
47
|
"zod": "^4.4.3"
|