@jam-mcp/server 1.4.4 → 1.4.6
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/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/cli/jira-read.d.ts +34 -0
- package/dist/cli/jira-read.js +98 -0
- package/dist/cli-entry.d.ts +1 -1
- package/dist/cli-entry.js +10 -0
- 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/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.6 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.6 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
|
|
@@ -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.6", "serve"];
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* Recognise wiring from before the launcher existed: a hard-coded `node` path
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading Jira without the MCP channel.
|
|
3
|
+
*
|
|
4
|
+
* JAM's reads have lived only behind MCP tools. That is the right home when the
|
|
5
|
+
* agent's session already has them - but a session that registers JAM cannot be
|
|
6
|
+
* shown the new tools: Claude Code has no reload surface (`claude mcp` offers
|
|
7
|
+
* add / get / list / login / remove / reset-project-choices / serve, and nothing
|
|
8
|
+
* that re-reads registrations for a running session). So the agent that just
|
|
9
|
+
* installed JAM has to wait for its next session before it can read anything.
|
|
10
|
+
*
|
|
11
|
+
* What it did instead was worse: it answered Jira questions from whatever was at
|
|
12
|
+
* hand - git log, the code host, project documents - which is the exact
|
|
13
|
+
* substitution JAM exists to prevent.
|
|
14
|
+
*
|
|
15
|
+
* This is the same read, addressed differently. `search` / `context` / `full`
|
|
16
|
+
* call the same application functions the tools call, with the same deps, the
|
|
17
|
+
* same policies and the same `meta`. Nothing here re-implements a read, and
|
|
18
|
+
* nothing here is a second source of truth.
|
|
19
|
+
*
|
|
20
|
+
* Contract, enforced by tests:
|
|
21
|
+
* stdout - one JSON document and nothing else, no ANSI, no prompts
|
|
22
|
+
* stderr - diagnostics only
|
|
23
|
+
*/
|
|
24
|
+
import { type BuildDepsOptions, type JamDeps } from "../deps.js";
|
|
25
|
+
export declare const JIRA_READ_USAGE = "Usage:\n jam jira search <jql> [--scope preview|complete]\n jam jira context <KEY> [KEY...]\n jam jira full <KEY> [KEY...]\n\nReads only. Output is one JSON document on stdout - the same result the MCP\ntools return, for a session that cannot see them yet.\n";
|
|
26
|
+
export type JiraReadOptions = BuildDepsOptions & {
|
|
27
|
+
/** Injected by tests so no test reaches a real Jira. */
|
|
28
|
+
deps?: JamDeps;
|
|
29
|
+
/** Where the JSON document goes. Defaults to stdout. */
|
|
30
|
+
write?: (text: string) => void;
|
|
31
|
+
/** Where diagnostics go. Defaults to stderr. */
|
|
32
|
+
warn?: (text: string) => void;
|
|
33
|
+
};
|
|
34
|
+
export declare function runJiraRead(argv: readonly string[], options?: JiraReadOptions): Promise<number>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading Jira without the MCP channel.
|
|
3
|
+
*
|
|
4
|
+
* JAM's reads have lived only behind MCP tools. That is the right home when the
|
|
5
|
+
* agent's session already has them - but a session that registers JAM cannot be
|
|
6
|
+
* shown the new tools: Claude Code has no reload surface (`claude mcp` offers
|
|
7
|
+
* add / get / list / login / remove / reset-project-choices / serve, and nothing
|
|
8
|
+
* that re-reads registrations for a running session). So the agent that just
|
|
9
|
+
* installed JAM has to wait for its next session before it can read anything.
|
|
10
|
+
*
|
|
11
|
+
* What it did instead was worse: it answered Jira questions from whatever was at
|
|
12
|
+
* hand - git log, the code host, project documents - which is the exact
|
|
13
|
+
* substitution JAM exists to prevent.
|
|
14
|
+
*
|
|
15
|
+
* This is the same read, addressed differently. `search` / `context` / `full`
|
|
16
|
+
* call the same application functions the tools call, with the same deps, the
|
|
17
|
+
* same policies and the same `meta`. Nothing here re-implements a read, and
|
|
18
|
+
* nothing here is a second source of truth.
|
|
19
|
+
*
|
|
20
|
+
* Contract, enforced by tests:
|
|
21
|
+
* stdout - one JSON document and nothing else, no ANSI, no prompts
|
|
22
|
+
* stderr - diagnostics only
|
|
23
|
+
*/
|
|
24
|
+
import { getFullIssueContext } from "../application/get-full-issue-context.js";
|
|
25
|
+
import { getIssueContext } from "../application/get-issue-context.js";
|
|
26
|
+
import { searchIssues } from "../application/search-issues.js";
|
|
27
|
+
import { buildDeps } from "../deps.js";
|
|
28
|
+
import { toJamError } from "../domain/errors.js";
|
|
29
|
+
export const JIRA_READ_USAGE = `Usage:
|
|
30
|
+
jam jira search <jql> [--scope preview|complete]
|
|
31
|
+
jam jira context <KEY> [KEY...]
|
|
32
|
+
jam jira full <KEY> [KEY...]
|
|
33
|
+
|
|
34
|
+
Reads only. Output is one JSON document on stdout - the same result the MCP
|
|
35
|
+
tools return, for a session that cannot see them yet.
|
|
36
|
+
`;
|
|
37
|
+
/** `--scope complete` / `--scope=complete`, and nothing invented when absent. */
|
|
38
|
+
function flagValue(argv, flag) {
|
|
39
|
+
const index = argv.indexOf(flag);
|
|
40
|
+
if (index >= 0)
|
|
41
|
+
return argv[index + 1];
|
|
42
|
+
const inline = argv.find((arg) => arg.startsWith(`${flag}=`));
|
|
43
|
+
return inline ? inline.slice(flag.length + 1) : undefined;
|
|
44
|
+
}
|
|
45
|
+
const positional = (argv) => {
|
|
46
|
+
const out = [];
|
|
47
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
48
|
+
const arg = argv[i];
|
|
49
|
+
if (arg === "--scope") {
|
|
50
|
+
i += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (arg.startsWith("--"))
|
|
54
|
+
continue;
|
|
55
|
+
out.push(arg);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
};
|
|
59
|
+
export async function runJiraRead(argv, options = {}) {
|
|
60
|
+
const write = options.write ?? ((text) => process.stdout.write(text));
|
|
61
|
+
const warn = options.warn ?? ((text) => process.stderr.write(text));
|
|
62
|
+
const [subcommand, ...rest] = argv;
|
|
63
|
+
if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
|
|
64
|
+
warn(JIRA_READ_USAGE);
|
|
65
|
+
return subcommand ? 0 : 1;
|
|
66
|
+
}
|
|
67
|
+
if (subcommand !== "search" && subcommand !== "context" && subcommand !== "full") {
|
|
68
|
+
warn(`Unknown jira command: ${subcommand}\n\n${JIRA_READ_USAGE}`);
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
const args = positional(rest);
|
|
72
|
+
if (args.length === 0) {
|
|
73
|
+
warn(subcommand === "search" ? "jam jira search needs a JQL query.\n" : `jam jira ${subcommand} needs at least one issue key.\n`);
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
const scope = flagValue(rest, "--scope");
|
|
77
|
+
if (scope !== undefined && scope !== "preview" && scope !== "complete") {
|
|
78
|
+
warn(`--scope is preview|complete (got ${scope}).\n`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const { deps: injected, write: _w, warn: _n, ...depsOptions } = options;
|
|
83
|
+
const deps = injected ?? (await buildDeps(depsOptions));
|
|
84
|
+
const result = subcommand === "search"
|
|
85
|
+
? await searchIssues(deps, { jql: args.join(" "), ...(scope ? { scope } : {}) })
|
|
86
|
+
: subcommand === "context"
|
|
87
|
+
? await getIssueContext(deps, { issueKeys: args })
|
|
88
|
+
: await getFullIssueContext(deps, { issueKeys: args });
|
|
89
|
+
write(`${JSON.stringify(result)}\n`);
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
// The same normalized codes the tools produce - an agent reads one contract,
|
|
94
|
+
// not two.
|
|
95
|
+
write(`${JSON.stringify(toJamError(err).toPayload())}\n`);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
package/dist/cli-entry.d.ts
CHANGED
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
* points (notably @jam-mcp/bootstrap) can forward to exactly these commands
|
|
4
4
|
* instead of reimplementing them.
|
|
5
5
|
*/
|
|
6
|
-
export declare const USAGE = "jam - Jira Agent MCP\n\nUsage:\n jam serve Run the MCP server over stdio (default; this is what Claude Code / Codex launch)\n jam doctor Diagnose config, credentials and Jira connectivity\n jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]\n Wire up this project and run doctor. Binds it to you\n alone, writing nothing to the repository; --shared\n adopts JAM for the team (project.yaml, .mcp.json)\n jam runtime Show which JAM build this machine runs\n jam runtime use package | development <path>\n Change it (writes ~/.jam/config.yaml only, never a project)\n jam auth login Store Jira credentials in this user's OS secret store\n jam auth logout Remove them again\n\nFor coding agents and scripts (stdout is JSON only, never prompts):\n jam setup --agent One shot: detect, plan, apply what is safe, verify\n jam setup plan --json Report what setup would change, changing nothing\n jam setup apply --non-interactive --json\n Execute the plan\n jam doctor --json Health check as structured output\n jam auth status --json Whether Jira credentials are configured (never their value)\n\nEnvironment:\n JIRA_BASE_URL https://your-site.atlassian.net\n JIRA_EMAIL Atlassian account email\n JIRA_API_TOKEN Atlassian API token\n JAM_PROJECT_KEY Jira project key, used by `jam setup`/`jam serve` when no\n .jira-agent/project.yaml exists yet\n\nCredentials and JAM_PROJECT_KEY are read from the current shell's environment\nfirst, then (on Windows) from the User environment - so a value set with\n`setx` works without opening a new terminal.\n";
|
|
6
|
+
export declare const USAGE = "jam - Jira Agent MCP\n\nUsage:\n jam serve Run the MCP server over stdio (default; this is what Claude Code / Codex launch)\n jam doctor Diagnose config, credentials and Jira connectivity\n jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]\n Wire up this project and run doctor. Binds it to you\n alone, writing nothing to the repository; --shared\n adopts JAM for the team (project.yaml, .mcp.json)\n jam runtime Show which JAM build this machine runs\n jam runtime use package | development <path>\n Change it (writes ~/.jam/config.yaml only, never a project)\n jam auth login Store Jira credentials in this user's OS secret store\n jam auth logout Remove them again\n\nFor coding agents and scripts (stdout is JSON only, never prompts):\n jam setup --agent One shot: detect, plan, apply what is safe, verify\n jam setup plan --json Report what setup would change, changing nothing\n jam setup apply --non-interactive --json\n Execute the plan\n jam doctor --json Health check as structured output\n jam auth status --json Whether Jira credentials are configured (never their value)\n jam jira search <jql> [--scope preview|complete]\n jam jira context <KEY> [KEY...]\n jam jira full <KEY> [KEY...]\n Read Jira from the shell - the same reads the MCP\n tools do, for a session that cannot see them yet\n\nEnvironment:\n JIRA_BASE_URL https://your-site.atlassian.net\n JIRA_EMAIL Atlassian account email\n JIRA_API_TOKEN Atlassian API token\n JAM_PROJECT_KEY Jira project key, used by `jam setup`/`jam serve` when no\n .jira-agent/project.yaml exists yet\n\nCredentials and JAM_PROJECT_KEY are read from the current shell's environment\nfirst, then (on Windows) from the User environment - so a value set with\n`setx` works without opening a new terminal.\n";
|
|
7
7
|
export declare function runJamCommand(argv: string[]): Promise<number>;
|
package/dist/cli-entry.js
CHANGED
|
@@ -6,6 +6,7 @@ import { setup } from "./cli/setup.js";
|
|
|
6
6
|
import { runSetupWizard } from "./cli/setup-wizard.js";
|
|
7
7
|
import { reportPromptError, Ui } from "./cli/ui.js";
|
|
8
8
|
import { authStatusCommand, doctorJsonCommand, setupAgentCommand, setupApplyCommand, setupPlanCommand, } from "./cli/agent-api.js";
|
|
9
|
+
import { runJiraRead } from "./cli/jira-read.js";
|
|
9
10
|
/**
|
|
10
11
|
* Command dispatch for the JAM CLI, separated from the bin so other entry
|
|
11
12
|
* points (notably @jam-mcp/bootstrap) can forward to exactly these commands
|
|
@@ -33,6 +34,11 @@ For coding agents and scripts (stdout is JSON only, never prompts):
|
|
|
33
34
|
Execute the plan
|
|
34
35
|
jam doctor --json Health check as structured output
|
|
35
36
|
jam auth status --json Whether Jira credentials are configured (never their value)
|
|
37
|
+
jam jira search <jql> [--scope preview|complete]
|
|
38
|
+
jam jira context <KEY> [KEY...]
|
|
39
|
+
jam jira full <KEY> [KEY...]
|
|
40
|
+
Read Jira from the shell - the same reads the MCP
|
|
41
|
+
tools do, for a session that cannot see them yet
|
|
36
42
|
|
|
37
43
|
Environment:
|
|
38
44
|
JIRA_BASE_URL https://your-site.atlassian.net
|
|
@@ -112,6 +118,10 @@ export async function runJamCommand(argv) {
|
|
|
112
118
|
process.stderr.write("Usage: jam auth login | status [--json] | logout\n");
|
|
113
119
|
return 1;
|
|
114
120
|
}
|
|
121
|
+
case "jira":
|
|
122
|
+
// Reads addressed to the shell, for a session that cannot see the MCP
|
|
123
|
+
// tools yet. Same application path as the tools - see cli/jira-read.ts.
|
|
124
|
+
return runJiraRead(rest);
|
|
115
125
|
case "help":
|
|
116
126
|
case "--help":
|
|
117
127
|
case "-h":
|
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] });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jam-mcp/server",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.6",
|
|
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.6",
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
46
46
|
"yaml": "^2.9.0",
|
|
47
47
|
"zod": "^4.4.3"
|