@jam-mcp/server 1.0.1 → 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 -72
- 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 +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 });
|
|
@@ -13,7 +13,7 @@ import { LAUNCHER_PACKAGE_SPEC } from "@jam-mcp/launcher";
|
|
|
13
13
|
export { LAUNCHER_PACKAGE_SPEC };
|
|
14
14
|
export declare const JAM_MCP_ENTRY: {
|
|
15
15
|
readonly command: "npx";
|
|
16
|
-
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.0
|
|
16
|
+
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.1.0", "serve"];
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* Recognise wiring from before the launcher existed: a hard-coded path to one
|
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;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The write contract.
|
|
3
|
+
*
|
|
4
|
+
* JAM's write plane is not a Jira REST proxy. An agent cannot hand JAM a
|
|
5
|
+
* mutation and have it forwarded; it describes an intent, JAM works out whether
|
|
6
|
+
* that intent is currently possible, and only a plan JAM itself produced can be
|
|
7
|
+
* applied. Everything in this file exists to keep that shape: a closed set of
|
|
8
|
+
* operations, a fixed field whitelist, and a plan that records what the issue
|
|
9
|
+
* looked like when the plan was made.
|
|
10
|
+
*
|
|
11
|
+
* Read semantics are deliberately not reused here. A read result's `meta`
|
|
12
|
+
* answers "how complete was this retrieval"; a write result answers "did this
|
|
13
|
+
* change happen, and did we see it happen". Mixing them would let a confident
|
|
14
|
+
* `complete: true` stand in for a verified mutation.
|
|
15
|
+
*/
|
|
16
|
+
/** The operations the public MCP surface accepts. Nothing else is reachable. */
|
|
17
|
+
export declare const WRITE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition"];
|
|
18
|
+
export type WriteOperation = (typeof WRITE_OPERATIONS)[number];
|
|
19
|
+
/**
|
|
20
|
+
* Fields `field.update` may touch.
|
|
21
|
+
*
|
|
22
|
+
* A whitelist rather than an open field map: an open map turns every
|
|
23
|
+
* project-specific screen, custom field and permission quirk into a runtime
|
|
24
|
+
* surprise, and makes "what can an agent change" unanswerable. Custom fields
|
|
25
|
+
* and assignee are deliberately absent - both need resolution work (schema
|
|
26
|
+
* discovery, accountId lookup) that belongs in its own round.
|
|
27
|
+
*/
|
|
28
|
+
export declare const WRITABLE_FIELDS: readonly ["summary", "priority", "labels", "components"];
|
|
29
|
+
export type WritableField = (typeof WRITABLE_FIELDS)[number];
|
|
30
|
+
export type CommentAddInput = {
|
|
31
|
+
text: string;
|
|
32
|
+
};
|
|
33
|
+
export type FieldUpdateInput = {
|
|
34
|
+
summary?: string;
|
|
35
|
+
priority?: string;
|
|
36
|
+
labels?: string[];
|
|
37
|
+
components?: string[];
|
|
38
|
+
};
|
|
39
|
+
export type StatusTransitionInput = {
|
|
40
|
+
status: string;
|
|
41
|
+
};
|
|
42
|
+
export type WriteInput = CommentAddInput | FieldUpdateInput | StatusTransitionInput;
|
|
43
|
+
/** A transition as Jira currently offers it for one issue. */
|
|
44
|
+
export type JiraTransition = {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
/** The status this transition leads to, as Jira names it. */
|
|
48
|
+
to: string;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* What a plan captured, and what it intends.
|
|
52
|
+
*
|
|
53
|
+
* `baseUpdated` is the issue's `updated` timestamp at plan time. Apply re-reads
|
|
54
|
+
* the issue and refuses when it has moved: a plan that was valid is not the
|
|
55
|
+
* same as a plan that is still valid.
|
|
56
|
+
*/
|
|
57
|
+
export type WritePlan = {
|
|
58
|
+
planId: string;
|
|
59
|
+
issueKey: string;
|
|
60
|
+
projectKey: string;
|
|
61
|
+
operation: WriteOperation;
|
|
62
|
+
/** Only the fields this operation touches. */
|
|
63
|
+
before: Record<string, unknown>;
|
|
64
|
+
intendedAfter: Record<string, unknown>;
|
|
65
|
+
baseUpdated: string;
|
|
66
|
+
createdAt: string;
|
|
67
|
+
expiresAt: string;
|
|
68
|
+
/**
|
|
69
|
+
* The transition Jira offered for this target status, resolved at plan time.
|
|
70
|
+
* Present only for `status.transition` - a transition id is never guessed
|
|
71
|
+
* from a status name.
|
|
72
|
+
*/
|
|
73
|
+
transition?: JiraTransition;
|
|
74
|
+
/** Normalized payload the apply step will send. Never supplied by a caller. */
|
|
75
|
+
mutation: WriteMutation;
|
|
76
|
+
};
|
|
77
|
+
/** What apply will actually send. Produced by planning, never by an agent. */
|
|
78
|
+
export type WriteMutation = {
|
|
79
|
+
kind: "comment";
|
|
80
|
+
text: string;
|
|
81
|
+
} | {
|
|
82
|
+
kind: "fields";
|
|
83
|
+
fields: Record<string, unknown>;
|
|
84
|
+
} | {
|
|
85
|
+
kind: "transition";
|
|
86
|
+
transitionId: string;
|
|
87
|
+
};
|
|
88
|
+
/** What `jira_write_plan` returns. The mutation itself is not exposed. */
|
|
89
|
+
export type WritePlanReceipt = {
|
|
90
|
+
status: "planned";
|
|
91
|
+
planId: string;
|
|
92
|
+
issue: string;
|
|
93
|
+
operation: WriteOperation;
|
|
94
|
+
before: Record<string, unknown>;
|
|
95
|
+
intendedAfter: Record<string, unknown>;
|
|
96
|
+
expiresAt: string;
|
|
97
|
+
/** How the result of applying this plan will be confirmed. */
|
|
98
|
+
verification: {
|
|
99
|
+
method: "direct-issue-read";
|
|
100
|
+
/** What a direct read must show before JAM calls the write applied. */
|
|
101
|
+
expects: Record<string, unknown>;
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
/** What `jira_write_apply` returns once a direct read has confirmed the change. */
|
|
105
|
+
export type WriteApplyReceipt = {
|
|
106
|
+
status: "applied";
|
|
107
|
+
issue: string;
|
|
108
|
+
operation: WriteOperation;
|
|
109
|
+
before: Record<string, unknown>;
|
|
110
|
+
after: Record<string, unknown>;
|
|
111
|
+
/** Always true in an `applied` receipt - an unverified write is an error. */
|
|
112
|
+
verified: true;
|
|
113
|
+
/** Present for `comment.add`: the comment Jira created. */
|
|
114
|
+
commentId?: string;
|
|
115
|
+
};
|
|
116
|
+
export declare function isWriteOperation(value: string): value is WriteOperation;
|
|
117
|
+
export declare function isWritableField(value: string): value is WritableField;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The write contract.
|
|
3
|
+
*
|
|
4
|
+
* JAM's write plane is not a Jira REST proxy. An agent cannot hand JAM a
|
|
5
|
+
* mutation and have it forwarded; it describes an intent, JAM works out whether
|
|
6
|
+
* that intent is currently possible, and only a plan JAM itself produced can be
|
|
7
|
+
* applied. Everything in this file exists to keep that shape: a closed set of
|
|
8
|
+
* operations, a fixed field whitelist, and a plan that records what the issue
|
|
9
|
+
* looked like when the plan was made.
|
|
10
|
+
*
|
|
11
|
+
* Read semantics are deliberately not reused here. A read result's `meta`
|
|
12
|
+
* answers "how complete was this retrieval"; a write result answers "did this
|
|
13
|
+
* change happen, and did we see it happen". Mixing them would let a confident
|
|
14
|
+
* `complete: true` stand in for a verified mutation.
|
|
15
|
+
*/
|
|
16
|
+
/** The operations the public MCP surface accepts. Nothing else is reachable. */
|
|
17
|
+
export const WRITE_OPERATIONS = ["comment.add", "field.update", "status.transition"];
|
|
18
|
+
/**
|
|
19
|
+
* Fields `field.update` may touch.
|
|
20
|
+
*
|
|
21
|
+
* A whitelist rather than an open field map: an open map turns every
|
|
22
|
+
* project-specific screen, custom field and permission quirk into a runtime
|
|
23
|
+
* surprise, and makes "what can an agent change" unanswerable. Custom fields
|
|
24
|
+
* and assignee are deliberately absent - both need resolution work (schema
|
|
25
|
+
* discovery, accountId lookup) that belongs in its own round.
|
|
26
|
+
*/
|
|
27
|
+
export const WRITABLE_FIELDS = ["summary", "priority", "labels", "components"];
|
|
28
|
+
export function isWriteOperation(value) {
|
|
29
|
+
return WRITE_OPERATIONS.includes(value);
|
|
30
|
+
}
|
|
31
|
+
export function isWritableField(value) {
|
|
32
|
+
return WRITABLE_FIELDS.includes(value);
|
|
33
|
+
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import type { JamDeps } from "../deps.js";
|
|
3
3
|
export declare const SERVER_NAME = "jam";
|
|
4
|
+
export declare const TOOL_COUNT: number;
|
|
4
5
|
/**
|
|
5
|
-
* The external contract:
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* The external contract: three read tools and two write tools.
|
|
7
|
+
*
|
|
8
|
+
* The read three have been stable since the first release and do not change.
|
|
9
|
+
* The write pair is a single operation split in half on purpose - deciding and
|
|
10
|
+
* doing are separate calls, so an agent cannot mutate Jira without first
|
|
11
|
+
* having been shown what it is about to change. Internal changes (cache, Rovo,
|
|
12
|
+
* remote transport) must not add or rename anything here.
|
|
8
13
|
*/
|
|
9
14
|
export declare function createServer(deps: JamDeps): McpServer;
|