@jam-mcp/server 1.0.0 → 1.1.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 -65
- 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-write.adapter.d.ts +35 -6
- package/dist/adapters/jira-cloud/jira-write.adapter.js +90 -10
- package/dist/application/apply-write.d.ts +25 -0
- package/dist/application/apply-write.js +151 -0
- package/dist/application/plan-write.d.ts +32 -0
- package/dist/application/plan-write.js +167 -0
- package/dist/application/write-plan-store.d.ts +42 -0
- package/dist/application/write-plan-store.js +69 -0
- package/dist/bootstrap/boot-health-gate.js +2 -2
- package/dist/bootstrap/mcp-config-merger.d.ts +8 -7
- package/dist/bootstrap/mcp-config-merger.js +7 -7
- package/dist/bootstrap/setup-plan.d.ts +8 -0
- package/dist/bootstrap/setup-plan.js +3 -2
- package/dist/cli/setup-wizard.js +1 -1
- package/dist/cli-entry.js +33 -33
- package/dist/deps.d.ts +15 -0
- package/dist/deps.js +8 -0
- package/dist/domain/errors.d.ts +1 -1
- package/dist/domain/errors.js +13 -0
- package/dist/domain/write.d.ts +117 -0
- package/dist/domain/write.js +33 -0
- package/dist/mcp/create-server.d.ts +8 -3
- package/dist/mcp/create-server.js +30 -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 +57 -0
- package/dist/policy/write-policy.d.ts +60 -0
- package/dist/policy/write-policy.js +114 -0
- package/dist/ports/jira-write.port.d.ts +18 -4
- package/package.json +69 -69
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { JamError } from "../domain/errors.js";
|
|
2
|
+
import { assertFieldsAllowed, assertOperationAllowed, assertWriteScope, PLAN_TTL_MS, resolveTransition, } from "../policy/write-policy.js";
|
|
3
|
+
/**
|
|
4
|
+
* Work out whether a requested change is currently possible, and describe it.
|
|
5
|
+
*
|
|
6
|
+
* Reads only. Nothing here mutates Jira, and that is the whole point of the
|
|
7
|
+
* step: the agent gets to see what the issue looks like now, what JAM would
|
|
8
|
+
* do to it, and what a direct read will have to show before JAM will call it
|
|
9
|
+
* done - all before anything has happened.
|
|
10
|
+
*
|
|
11
|
+
* The order matters. Scope and operation are checked before any Jira call, so
|
|
12
|
+
* an out-of-scope key costs nothing and comes back as a JAM refusal rather
|
|
13
|
+
* than a 404. Everything after that is derived from the issue as Jira reports
|
|
14
|
+
* it right now, never from what the caller asserted about it.
|
|
15
|
+
*/
|
|
16
|
+
export async function planWrite(deps, request) {
|
|
17
|
+
const issueKey = request.key.trim().toUpperCase();
|
|
18
|
+
const projectKey = assertWriteScope(issueKey, deps.config.project.key);
|
|
19
|
+
const operation = assertOperationAllowed(request.operation);
|
|
20
|
+
// Everything that can be refused from the request alone is refused here,
|
|
21
|
+
// before a Jira call is spent on it. An agent asking to write a field JAM
|
|
22
|
+
// does not write should get that answer, not a round trip and then that
|
|
23
|
+
// answer.
|
|
24
|
+
const input = validateInput(operation, request.input);
|
|
25
|
+
const issue = await readIssue(deps, issueKey);
|
|
26
|
+
const { before, intendedAfter, mutation, transition } = await describe(deps, operation, issueKey, issue, input);
|
|
27
|
+
const createdAt = new Date();
|
|
28
|
+
const plan = deps.writePlans.create({
|
|
29
|
+
issueKey,
|
|
30
|
+
projectKey,
|
|
31
|
+
operation,
|
|
32
|
+
before,
|
|
33
|
+
intendedAfter,
|
|
34
|
+
baseUpdated: issue.updated,
|
|
35
|
+
createdAt: createdAt.toISOString(),
|
|
36
|
+
expiresAt: new Date(createdAt.getTime() + PLAN_TTL_MS).toISOString(),
|
|
37
|
+
...(transition ? { transition } : {}),
|
|
38
|
+
mutation,
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
plan,
|
|
42
|
+
receipt: {
|
|
43
|
+
status: "planned",
|
|
44
|
+
planId: plan.planId,
|
|
45
|
+
issue: plan.issueKey,
|
|
46
|
+
operation: plan.operation,
|
|
47
|
+
before: plan.before,
|
|
48
|
+
intendedAfter: plan.intendedAfter,
|
|
49
|
+
expiresAt: plan.expiresAt,
|
|
50
|
+
verification: { method: "direct-issue-read", expects: plan.intendedAfter },
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The issue as Jira has it, read directly by key.
|
|
56
|
+
*
|
|
57
|
+
* A direct read, never a search: ConsistencyPolicy requires it for anything
|
|
58
|
+
* that decides a write, and a JQL result can lag behind the issue it describes.
|
|
59
|
+
*/
|
|
60
|
+
export async function readIssue(deps, issueKey) {
|
|
61
|
+
const { issues } = await deps.jira.getIssues({
|
|
62
|
+
keys: [issueKey],
|
|
63
|
+
fields: ["summary", "status", "priority", "labels", "components", "updated"],
|
|
64
|
+
});
|
|
65
|
+
const issue = issues[0];
|
|
66
|
+
if (!issue) {
|
|
67
|
+
throw new JamError("ISSUE_NOT_FOUND", `Jira has no issue ${issueKey}, or it is not visible to this account.`, { issueKey });
|
|
68
|
+
}
|
|
69
|
+
return issue;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Check the request against the contract, and normalize it.
|
|
73
|
+
*
|
|
74
|
+
* Pure: no Jira, no state. Whether an operation is supported, whether a field
|
|
75
|
+
* is writable and whether the input is even the right shape are all knowable
|
|
76
|
+
* without asking Jira anything, so they are answered first.
|
|
77
|
+
*/
|
|
78
|
+
function validateInput(operation, raw) {
|
|
79
|
+
switch (operation) {
|
|
80
|
+
case "comment.add": {
|
|
81
|
+
const text = raw.text;
|
|
82
|
+
if (typeof text !== "string" || text.trim().length === 0) {
|
|
83
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "comment.add needs non-empty `input.text`.", { operation });
|
|
84
|
+
}
|
|
85
|
+
return { text: text.trim() };
|
|
86
|
+
}
|
|
87
|
+
case "field.update":
|
|
88
|
+
return assertFieldsAllowed(raw);
|
|
89
|
+
case "status.transition": {
|
|
90
|
+
const status = raw.status;
|
|
91
|
+
if (typeof status !== "string" || status.trim().length === 0) {
|
|
92
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "status.transition needs non-empty `input.status`.", { operation });
|
|
93
|
+
}
|
|
94
|
+
return { status: status.trim() };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function describe(deps, operation, issueKey, issue, input) {
|
|
99
|
+
switch (operation) {
|
|
100
|
+
case "comment.add": {
|
|
101
|
+
const { text } = input;
|
|
102
|
+
// Comments accumulate rather than replace, so `before` says what is
|
|
103
|
+
// there now by count - the plan is not claiming to know the thread.
|
|
104
|
+
return {
|
|
105
|
+
before: { comments: issue.comments.length },
|
|
106
|
+
intendedAfter: { commentAdded: text },
|
|
107
|
+
mutation: { kind: "comment", text },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
case "field.update": {
|
|
111
|
+
const fields = input;
|
|
112
|
+
const before = {};
|
|
113
|
+
const after = {};
|
|
114
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
115
|
+
before[field] = currentValue(issue, field);
|
|
116
|
+
after[field] = value;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
before,
|
|
120
|
+
intendedAfter: after,
|
|
121
|
+
mutation: { kind: "fields", fields: toJiraFields(fields) },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
case "status.transition": {
|
|
125
|
+
const { status: target } = input;
|
|
126
|
+
// Ask Jira what is reachable rather than deriving an id from a name:
|
|
127
|
+
// transition ids are per-workflow, and a guessed one either fails or
|
|
128
|
+
// moves the issue somewhere nobody asked for.
|
|
129
|
+
const available = await deps.jiraWrite.getTransitions(issueKey);
|
|
130
|
+
const transition = resolveTransition(target, available);
|
|
131
|
+
return {
|
|
132
|
+
before: { status: issue.status },
|
|
133
|
+
intendedAfter: { status: transition.to },
|
|
134
|
+
mutation: { kind: "transition", transitionId: transition.id },
|
|
135
|
+
transition,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function currentValue(issue, field) {
|
|
141
|
+
switch (field) {
|
|
142
|
+
case "summary":
|
|
143
|
+
return issue.summary;
|
|
144
|
+
case "priority":
|
|
145
|
+
return issue.priority;
|
|
146
|
+
case "labels":
|
|
147
|
+
return issue.labels;
|
|
148
|
+
case "components":
|
|
149
|
+
return issue.components;
|
|
150
|
+
default:
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Whitelisted values to the shapes Jira's field API expects. */
|
|
155
|
+
function toJiraFields(input) {
|
|
156
|
+
const fields = {};
|
|
157
|
+
if (input.summary !== undefined)
|
|
158
|
+
fields["summary"] = input.summary;
|
|
159
|
+
if (input.priority !== undefined)
|
|
160
|
+
fields["priority"] = { name: input.priority };
|
|
161
|
+
if (input.labels !== undefined)
|
|
162
|
+
fields["labels"] = input.labels;
|
|
163
|
+
if (input.components !== undefined) {
|
|
164
|
+
fields["components"] = input.components.map((name) => ({ name }));
|
|
165
|
+
}
|
|
166
|
+
return fields;
|
|
167
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { 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: Omit<WritePlan, "planId">): 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,69 @@
|
|
|
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
|
+
throw new JamError("JAM_WRITE_PLAN_EXPIRED", `This write plan expired at ${plan.expiresAt}. Re-plan against the current state of ${plan.issueKey}.`, { planId, issueKey: plan.issueKey, expiresAt: plan.expiresAt });
|
|
49
|
+
}
|
|
50
|
+
return plan;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Retire a plan once it has been applied.
|
|
54
|
+
*
|
|
55
|
+
* Single use, so a receipt cannot be turned into a second mutation by
|
|
56
|
+
* calling apply again with the same id - which for `comment.add` would mean
|
|
57
|
+
* two comments.
|
|
58
|
+
*/
|
|
59
|
+
consume(planId) {
|
|
60
|
+
this.plans.delete(planId);
|
|
61
|
+
}
|
|
62
|
+
evictExpired() {
|
|
63
|
+
const now = this.now();
|
|
64
|
+
for (const [id, plan] of this.plans) {
|
|
65
|
+
if (planExpired(plan.expiresAt, now))
|
|
66
|
+
this.plans.delete(id);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -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 });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LAUNCHER_PACKAGE_SPEC } from "@jam-mcp/launcher";
|
|
1
2
|
/**
|
|
2
3
|
* The canonical jam entry for a project's `.mcp.json`.
|
|
3
4
|
*
|
|
@@ -9,10 +10,10 @@
|
|
|
9
10
|
* Pinned to an exact version. A floating tag would silently change what a
|
|
10
11
|
* teammate's editor launches.
|
|
11
12
|
*/
|
|
12
|
-
export
|
|
13
|
+
export { LAUNCHER_PACKAGE_SPEC };
|
|
13
14
|
export declare const JAM_MCP_ENTRY: {
|
|
14
15
|
readonly command: "npx";
|
|
15
|
-
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.
|
|
16
|
+
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.1.0", "serve"];
|
|
16
17
|
};
|
|
17
18
|
/**
|
|
18
19
|
* Recognise wiring from before the launcher existed: a hard-coded path to one
|
|
@@ -48,11 +49,11 @@ export declare function inspectMcpConfig(root: string): McpInspection;
|
|
|
48
49
|
*/
|
|
49
50
|
export declare function writeJamMcpEntry(root: string, entry?: unknown): string;
|
|
50
51
|
/**
|
|
51
|
-
* Merge
|
|
52
|
+
* Merge the launcher entry into `.mcp.json`, preserving everything else.
|
|
52
53
|
*
|
|
53
|
-
* Deliberately
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* Deliberately records neither an absolute path to a JAM checkout nor a bare
|
|
55
|
+
* `jam` - the first breaks the moment a teammate clones somewhere else, and
|
|
56
|
+
* the second assumes an optional global install every teammate would have to
|
|
57
|
+
* have made. `npx` at an exact launcher version assumes only npm.
|
|
57
58
|
*/
|
|
58
59
|
export declare function mergeMcpConfig(root: string): McpMergeResult;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { LAUNCHER_PACKAGE_SPEC } from "@jam-mcp/launcher";
|
|
4
4
|
/**
|
|
5
5
|
* The canonical jam entry for a project's `.mcp.json`.
|
|
6
6
|
*
|
|
@@ -12,7 +12,7 @@ import { SERVER_VERSION } from "@jam-mcp/launcher";
|
|
|
12
12
|
* Pinned to an exact version. A floating tag would silently change what a
|
|
13
13
|
* teammate's editor launches.
|
|
14
14
|
*/
|
|
15
|
-
export
|
|
15
|
+
export { LAUNCHER_PACKAGE_SPEC };
|
|
16
16
|
export const JAM_MCP_ENTRY = {
|
|
17
17
|
command: "npx",
|
|
18
18
|
args: ["--yes", LAUNCHER_PACKAGE_SPEC, "serve"],
|
|
@@ -86,12 +86,12 @@ export function writeJamMcpEntry(root, entry = JAM_MCP_ENTRY) {
|
|
|
86
86
|
return path;
|
|
87
87
|
}
|
|
88
88
|
/**
|
|
89
|
-
* Merge
|
|
89
|
+
* Merge the launcher entry into `.mcp.json`, preserving everything else.
|
|
90
90
|
*
|
|
91
|
-
* Deliberately
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
91
|
+
* Deliberately records neither an absolute path to a JAM checkout nor a bare
|
|
92
|
+
* `jam` - the first breaks the moment a teammate clones somewhere else, and
|
|
93
|
+
* the second assumes an optional global install every teammate would have to
|
|
94
|
+
* have made. `npx` at an exact launcher version assumes only npm.
|
|
95
95
|
*/
|
|
96
96
|
export function mergeMcpConfig(root) {
|
|
97
97
|
const path = join(root, ".mcp.json");
|
|
@@ -57,6 +57,14 @@ export type SetupPlan = {
|
|
|
57
57
|
}[];
|
|
58
58
|
/** Why a requested migration was refused, when status is JAM_MIGRATION_TARGET_UNAVAILABLE. */
|
|
59
59
|
migrationTarget?: MigrationTarget;
|
|
60
|
+
/**
|
|
61
|
+
* What a person or an agent has to do next.
|
|
62
|
+
*
|
|
63
|
+
* `command` is executable on a machine with nothing installed and no runtime
|
|
64
|
+
* configured - so it is an `npx` bootstrap invocation, never a bare `jam`.
|
|
65
|
+
* A human interface is free to render the short form; this field is the one
|
|
66
|
+
* a script runs, and a script has no PATH to rely on.
|
|
67
|
+
*/
|
|
60
68
|
nextAction?: {
|
|
61
69
|
type: "authenticate" | "select_project" | "configure_runtime";
|
|
62
70
|
command?: string;
|
|
@@ -3,6 +3,7 @@ import { CONFIG_RELATIVE_PATH } from "../config/load-config.js";
|
|
|
3
3
|
import { hostRegistration } from "./host-mcp.js";
|
|
4
4
|
import { projectBindingsPath } from "./project-bindings.js";
|
|
5
5
|
import { decideProjectKey } from "./project-config-bootstrapper.js";
|
|
6
|
+
import { portableBootstrapCommand } from "@jam-mcp/launcher";
|
|
6
7
|
/**
|
|
7
8
|
* Decide what setup would do, changing nothing.
|
|
8
9
|
*
|
|
@@ -47,7 +48,7 @@ export function computeSetupPlan(state, options = {}) {
|
|
|
47
48
|
code: "JAM_PROJECT_SELECTION_REQUIRED",
|
|
48
49
|
changes: [],
|
|
49
50
|
requiresUserAction: true,
|
|
50
|
-
nextAction: { type: "select_project", command: "
|
|
51
|
+
nextAction: { type: "select_project", command: portableBootstrapCommand("setup --project <KEY>") },
|
|
51
52
|
project: { root: state.project.root },
|
|
52
53
|
};
|
|
53
54
|
}
|
|
@@ -122,7 +123,7 @@ function finish(changes, state, project) {
|
|
|
122
123
|
code: "JAM_RUNTIME_CONFIG_MISSING",
|
|
123
124
|
changes,
|
|
124
125
|
requiresUserAction: true,
|
|
125
|
-
nextAction: { type: "configure_runtime", command: "
|
|
126
|
+
nextAction: { type: "configure_runtime", command: portableBootstrapCommand("runtime use package") },
|
|
126
127
|
project,
|
|
127
128
|
};
|
|
128
129
|
}
|
package/dist/cli/setup-wizard.js
CHANGED
|
@@ -133,7 +133,7 @@ async function chooseRuntime(ui, options) {
|
|
|
133
133
|
{
|
|
134
134
|
value: "package",
|
|
135
135
|
label: "Use JAM",
|
|
136
|
-
hint: "Run the
|
|
136
|
+
hint: "Run the published JAM release. Recommended for most users.",
|
|
137
137
|
},
|
|
138
138
|
{ value: "development", label: "Develop JAM", hint: "Run a local source checkout." },
|
|
139
139
|
], "Run: jam runtime use package");
|
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,8 @@ 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 { JiraWritePort } from "./ports/jira-write.port.js";
|
|
8
|
+
import { WritePlanStore } from "./application/write-plan-store.js";
|
|
7
9
|
import type { TelemetryPort } from "./ports/telemetry.port.js";
|
|
8
10
|
/** Everything the application layer is allowed to reach for. */
|
|
9
11
|
export type JamDeps = {
|
|
@@ -12,6 +14,17 @@ export type JamDeps = {
|
|
|
12
14
|
/** Where the project key came from when no config file supplied one. */
|
|
13
15
|
keySource?: BootstrapSource;
|
|
14
16
|
jira: JiraReadPort;
|
|
17
|
+
/**
|
|
18
|
+
* The mutating half. Separate from `jira` on purpose: reading and writing
|
|
19
|
+
* have different retry rules and different confirmation rules, and one port
|
|
20
|
+
* that did both would blur them.
|
|
21
|
+
*/
|
|
22
|
+
jiraWrite: JiraWritePort;
|
|
23
|
+
/**
|
|
24
|
+
* Plans awaiting apply. Lives for the life of this server process - see
|
|
25
|
+
* WritePlanStore for why it is not persisted.
|
|
26
|
+
*/
|
|
27
|
+
writePlans: WritePlanStore;
|
|
15
28
|
cache: CachePort;
|
|
16
29
|
telemetry: TelemetryPort;
|
|
17
30
|
credentials: CredentialPort;
|
|
@@ -20,6 +33,8 @@ export type BuildDepsOptions = {
|
|
|
20
33
|
cwd?: string;
|
|
21
34
|
/** Injected by tests to bypass the real REST adapter. */
|
|
22
35
|
jira?: JiraReadPort;
|
|
36
|
+
/** Injected by tests so no test can reach a real Jira write endpoint. */
|
|
37
|
+
jiraWrite?: JiraWritePort;
|
|
23
38
|
/** Injected by tests to bypass the real process/registry credential lookup. */
|
|
24
39
|
credentials?: CredentialPort;
|
|
25
40
|
/**
|
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,18 @@ 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
|
+
}
|
|
30
36
|
return {
|
|
31
37
|
config: resolved.config,
|
|
32
38
|
configPath: resolved.configPath,
|
|
33
39
|
keySource: resolved.keySource,
|
|
34
40
|
jira,
|
|
41
|
+
jiraWrite,
|
|
42
|
+
writePlans: new WritePlanStore(),
|
|
35
43
|
cache: new NoopCache(),
|
|
36
44
|
telemetry,
|
|
37
45
|
credentials,
|
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_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: {
|
package/dist/domain/errors.js
CHANGED
|
@@ -17,6 +17,19 @@ export const JAM_ERROR_CODES = [
|
|
|
17
17
|
"JIRA_UNAVAILABLE",
|
|
18
18
|
"JAM_SETUP_REQUIRED",
|
|
19
19
|
"JAM_BINDINGS_UNREADABLE",
|
|
20
|
+
// Write plane. Each names a distinct decision an agent has to make, which is
|
|
21
|
+
// why none of them collapse into CONFIG_INVALID: "you may not write there",
|
|
22
|
+
// "someone else moved it", and "we could not confirm it happened" call for
|
|
23
|
+
// three different next steps.
|
|
24
|
+
"JAM_WRITE_SCOPE_VIOLATION",
|
|
25
|
+
"JAM_WRITE_OPERATION_NOT_ALLOWED",
|
|
26
|
+
"JAM_WRITE_FIELD_NOT_ALLOWED",
|
|
27
|
+
"JAM_WRITE_TRANSITION_NOT_AVAILABLE",
|
|
28
|
+
"JAM_WRITE_PLAN_NOT_FOUND",
|
|
29
|
+
"JAM_WRITE_PLAN_EXPIRED",
|
|
30
|
+
"JAM_WRITE_CONFLICT",
|
|
31
|
+
"JAM_WRITE_VERIFICATION_FAILED",
|
|
32
|
+
"JAM_WRITE_UNCERTAIN",
|
|
20
33
|
];
|
|
21
34
|
export class JamError extends Error {
|
|
22
35
|
code;
|