@jam-mcp/server 1.3.0 → 1.3.1
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 +90 -86
- package/dist/adapters/jira-cloud/jira-edit-metadata.adapter.d.ts +25 -0
- package/dist/adapters/jira-cloud/jira-edit-metadata.adapter.js +84 -0
- package/dist/adapters/jira-cloud/jira-read.adapter.js +11 -1
- package/dist/application/apply-write.js +52 -5
- package/dist/application/plan-write.d.ts +15 -2
- package/dist/application/plan-write.js +108 -6
- package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
- package/dist/bootstrap/setup-plan.d.ts +11 -0
- package/dist/bootstrap/setup-plan.js +10 -1
- package/dist/cli-entry.js +33 -33
- package/dist/config/schema.d.ts +1 -0
- package/dist/config/schema.js +35 -1
- package/dist/deps.d.ts +9 -0
- package/dist/deps.js +6 -0
- package/dist/domain/errors.d.ts +1 -1
- package/dist/domain/errors.js +6 -0
- package/dist/domain/write.d.ts +99 -3
- package/dist/domain/write.js +11 -0
- package/dist/index.js +0 -0
- package/dist/mcp/tools/jira-write-apply.tool.js +14 -14
- package/dist/mcp/tools/jira-write-plan.tool.js +33 -21
- package/dist/policy/custom-field-policy.d.ts +93 -0
- package/dist/policy/custom-field-policy.js +230 -0
- package/dist/ports/jira-edit-metadata.port.d.ts +22 -0
- package/dist/ports/jira-edit-metadata.port.js +1 -0
- package/dist/ports/jira-read.port.d.ts +10 -0
- package/package.json +69 -69
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { JamError } from "../domain/errors.js";
|
|
2
2
|
import { assertExistingIssueOperation, assertFieldsAllowed, assertOperationAllowed, assertWriteScope, PLAN_TTL_MS, resolveTransition, } from "../policy/write-policy.js";
|
|
3
3
|
import { assertAssignable, assertNotAlreadyAssigned, exactMatches, resolveAssignee, } from "../policy/assignee-policy.js";
|
|
4
|
+
import { assertEditable, classifyKind, resolveCustomFieldValue, resolveWritableField, } from "../policy/custom-field-policy.js";
|
|
4
5
|
import { planCreateIssue } from "./plan-create-issue.js";
|
|
5
6
|
/**
|
|
6
7
|
* Work out whether a requested change is currently possible, and describe it.
|
|
@@ -31,9 +32,15 @@ export async function planWrite(deps, request) {
|
|
|
31
32
|
// does not write should get that answer, not a round trip and then that
|
|
32
33
|
// answer.
|
|
33
34
|
const input = validateInput(operation, request.input);
|
|
34
|
-
|
|
35
|
+
// Which custom field the whitelist says this is, settled before the read so
|
|
36
|
+
// the read can fetch its current value in the same request. A selector the
|
|
37
|
+
// team never opted in costs no Jira call at all.
|
|
38
|
+
const targetField = operation === "custom-field.update"
|
|
39
|
+
? resolveWritableField(deps.config, input.field)
|
|
40
|
+
: undefined;
|
|
41
|
+
const snapshot = await readIssue(deps, issueKey, targetField ? [targetField.id] : []);
|
|
35
42
|
const issue = snapshot.issue;
|
|
36
|
-
const { before, intendedAfter, mutation, transition, baseAssigneeAccountId } = await describe(deps, operation, issueKey, snapshot, input);
|
|
43
|
+
const { before, intendedAfter, mutation, transition, baseAssigneeAccountId, customFieldRequirements } = await describe(deps, operation, issueKey, snapshot, input, targetField);
|
|
37
44
|
const createdAt = new Date();
|
|
38
45
|
const plan = deps.writePlans.create({
|
|
39
46
|
kind: "existing-issue",
|
|
@@ -47,6 +54,7 @@ export async function planWrite(deps, request) {
|
|
|
47
54
|
expiresAt: new Date(createdAt.getTime() + PLAN_TTL_MS).toISOString(),
|
|
48
55
|
...(transition ? { transition } : {}),
|
|
49
56
|
...(baseAssigneeAccountId ? { baseAssigneeAccountId } : {}),
|
|
57
|
+
...(customFieldRequirements ? { customFieldRequirements } : {}),
|
|
50
58
|
mutation,
|
|
51
59
|
});
|
|
52
60
|
return {
|
|
@@ -63,8 +71,8 @@ export async function planWrite(deps, request) {
|
|
|
63
71
|
},
|
|
64
72
|
};
|
|
65
73
|
}
|
|
66
|
-
export async function readIssue(deps, issueKey) {
|
|
67
|
-
const { issue: found, assigneeAccountId } = await deps.jira.getIssue({
|
|
74
|
+
export async function readIssue(deps, issueKey, extraFields = []) {
|
|
75
|
+
const { issue: found, assigneeAccountId, customFieldValues } = await deps.jira.getIssue({
|
|
68
76
|
key: issueKey,
|
|
69
77
|
// `issuetype` and `description` are here for creation's verification step,
|
|
70
78
|
// which has to confirm the issue Jira made is the one that was asked for.
|
|
@@ -81,13 +89,33 @@ export async function readIssue(deps, issueKey) {
|
|
|
81
89
|
"labels",
|
|
82
90
|
"components",
|
|
83
91
|
"updated",
|
|
92
|
+
// A custom field is only read when one is being written, and then only
|
|
93
|
+
// that one - so a custom-field update still costs a single direct GET
|
|
94
|
+
// rather than a second read for the field it is about to change.
|
|
95
|
+
...extraFields.filter((f) => !BASE_WRITE_FIELDS.has(f)),
|
|
84
96
|
],
|
|
85
97
|
});
|
|
86
98
|
if (!found) {
|
|
87
99
|
throw new JamError("ISSUE_NOT_FOUND", `Jira has no issue ${issueKey}, or it is not visible to this account.`, { issueKey });
|
|
88
100
|
}
|
|
89
|
-
return {
|
|
101
|
+
return {
|
|
102
|
+
issue: found,
|
|
103
|
+
...(assigneeAccountId ? { assigneeAccountId } : {}),
|
|
104
|
+
...(customFieldValues ? { customFieldValues } : {}),
|
|
105
|
+
};
|
|
90
106
|
}
|
|
107
|
+
/** Requested on every write-plane read, so an extra field is never a duplicate. */
|
|
108
|
+
const BASE_WRITE_FIELDS = new Set([
|
|
109
|
+
"summary",
|
|
110
|
+
"status",
|
|
111
|
+
"issuetype",
|
|
112
|
+
"description",
|
|
113
|
+
"assignee",
|
|
114
|
+
"priority",
|
|
115
|
+
"labels",
|
|
116
|
+
"components",
|
|
117
|
+
"updated",
|
|
118
|
+
]);
|
|
91
119
|
/**
|
|
92
120
|
* The issue an existing-issue operation names, or a refusal that says why.
|
|
93
121
|
*
|
|
@@ -128,6 +156,30 @@ function validateInput(operation, raw) {
|
|
|
128
156
|
}
|
|
129
157
|
return { status: status.trim() };
|
|
130
158
|
}
|
|
159
|
+
case "custom-field.update": {
|
|
160
|
+
// Anything outside this operation's own two keys is refused rather than
|
|
161
|
+
// ignored. Silently dropping a key an agent supplied is how a caller
|
|
162
|
+
// ends up with a write that is not the one it described - and the shared
|
|
163
|
+
// input object means another operation's key is a plausible mistake.
|
|
164
|
+
const extra = Object.keys(raw).filter((k) => raw[k] !== undefined && k !== "field" && k !== "value");
|
|
165
|
+
if (extra.length > 0) {
|
|
166
|
+
throw new JamError("JAM_WRITE_FIELD_NOT_ALLOWED", `custom-field.update takes only \`field\` and \`value\`. Remove: ${extra.join(", ")}.`, { operation, rejected: extra });
|
|
167
|
+
}
|
|
168
|
+
const { field, value } = raw;
|
|
169
|
+
if (typeof field !== "string" || field.trim().length === 0) {
|
|
170
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "custom-field.update needs non-empty `input.field` - a configured custom field id or name.", { operation });
|
|
171
|
+
}
|
|
172
|
+
// The value's family is checked against Jira's schema later; what is
|
|
173
|
+
// checked here is that it is a shape the contract admits at all. An
|
|
174
|
+
// object, a boolean or a null never reaches the type policy.
|
|
175
|
+
const isString = typeof value === "string";
|
|
176
|
+
const isNumber = typeof value === "number";
|
|
177
|
+
const isStringArray = Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
178
|
+
if (!isString && !isNumber && !isStringArray) {
|
|
179
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "custom-field.update needs `input.value` to be a string, a number, or an array of strings.", { operation, received: Array.isArray(value) ? "array" : typeof value });
|
|
180
|
+
}
|
|
181
|
+
return { field: field.trim(), value };
|
|
182
|
+
}
|
|
131
183
|
case "assignee.update": {
|
|
132
184
|
const assignee = raw.assignee;
|
|
133
185
|
if (typeof assignee !== "string" || assignee.trim().length === 0) {
|
|
@@ -137,7 +189,7 @@ function validateInput(operation, raw) {
|
|
|
137
189
|
}
|
|
138
190
|
}
|
|
139
191
|
}
|
|
140
|
-
async function describe(deps, operation, issueKey, snapshot, input) {
|
|
192
|
+
async function describe(deps, operation, issueKey, snapshot, input, targetField) {
|
|
141
193
|
const issue = snapshot.issue;
|
|
142
194
|
switch (operation) {
|
|
143
195
|
case "comment.add": {
|
|
@@ -178,6 +230,31 @@ async function describe(deps, operation, issueKey, snapshot, input) {
|
|
|
178
230
|
transition,
|
|
179
231
|
};
|
|
180
232
|
}
|
|
233
|
+
case "custom-field.update": {
|
|
234
|
+
const field = targetField;
|
|
235
|
+
const requested = input;
|
|
236
|
+
// Jira decides what is editable here and now, and in what shape. JAM
|
|
237
|
+
// does not model project contexts, screens or permissions - it asks the
|
|
238
|
+
// one endpoint that answers all three at once for this issue.
|
|
239
|
+
const metadata = await deps.jiraEditMetadata.getEditableFields(issueKey);
|
|
240
|
+
const editable = assertEditable(issueKey, field, metadata);
|
|
241
|
+
const kind = classifyKind(editable);
|
|
242
|
+
const { jiraValue, view, resolvedOptions } = resolveCustomFieldValue(editable, kind, requested);
|
|
243
|
+
return {
|
|
244
|
+
before: {
|
|
245
|
+
customField: currentCustomFieldView(field, kind, snapshot.customFieldValues?.[field.id]),
|
|
246
|
+
},
|
|
247
|
+
intendedAfter: { customField: view },
|
|
248
|
+
mutation: { kind: "custom-field", fieldId: field.id, value: jiraValue },
|
|
249
|
+
customFieldRequirements: {
|
|
250
|
+
fieldId: field.id,
|
|
251
|
+
fieldName: field.name,
|
|
252
|
+
kind,
|
|
253
|
+
schema: editable.schema,
|
|
254
|
+
...(resolvedOptions ? { resolvedOptions } : {}),
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
181
258
|
case "assignee.update": {
|
|
182
259
|
const { assignee: requested } = input;
|
|
183
260
|
// Ask Jira who this is, and decide from what it says. The requested
|
|
@@ -240,6 +317,31 @@ function currentValue(issue, field) {
|
|
|
240
317
|
return undefined;
|
|
241
318
|
}
|
|
242
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* What the field holds now, in the shape a receipt shows.
|
|
322
|
+
*
|
|
323
|
+
* Jira stores an option as an object and a scalar as itself; a person reading
|
|
324
|
+
* `before` wants the same canonical form they will see in `intendedAfter`, so
|
|
325
|
+
* they can compare the two rather than a payload against a summary.
|
|
326
|
+
*/
|
|
327
|
+
export function currentCustomFieldView(field, kind, raw) {
|
|
328
|
+
const named = { id: field.id, name: field.name };
|
|
329
|
+
if (kind === "multi-option") {
|
|
330
|
+
return { ...named, value: Array.isArray(raw) ? raw.map(toOptionView) : [] };
|
|
331
|
+
}
|
|
332
|
+
if (kind === "single-option") {
|
|
333
|
+
return { ...named, value: raw == null ? null : toOptionView(raw) };
|
|
334
|
+
}
|
|
335
|
+
if (raw == null)
|
|
336
|
+
return { ...named, value: null };
|
|
337
|
+
return { ...named, value: kind === "number" ? Number(raw) : String(raw) };
|
|
338
|
+
}
|
|
339
|
+
function toOptionView(raw) {
|
|
340
|
+
const o = raw;
|
|
341
|
+
const id = typeof o?.id === "string" ? o.id : typeof o?.id === "number" ? String(o.id) : "";
|
|
342
|
+
const label = typeof o?.value === "string" ? o.value : typeof o?.name === "string" ? o.name : String(raw);
|
|
343
|
+
return { id, label };
|
|
344
|
+
}
|
|
243
345
|
/** Whitelisted values to the shapes Jira's field API expects. */
|
|
244
346
|
function toJiraFields(input) {
|
|
245
347
|
const fields = {};
|
|
@@ -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.3.
|
|
16
|
+
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.3.1", "serve"];
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* Recognise wiring from before the launcher existed: a hard-coded path to one
|
|
@@ -64,10 +64,21 @@ export type SetupPlan = {
|
|
|
64
64
|
* configured - so it is an `npx` bootstrap invocation, never a bare `jam`.
|
|
65
65
|
* A human interface is free to render the short form; this field is the one
|
|
66
66
|
* a script runs, and a script has no PATH to rely on.
|
|
67
|
+
*
|
|
68
|
+
* `userCommand` is the opposite: a command for the person, which the agent
|
|
69
|
+
* relays and never runs. Authentication is the only step of that shape, and
|
|
70
|
+
* it carries no `command` precisely so that no caller can execute it. The
|
|
71
|
+
* separation is the point - one field is for running, the other for showing.
|
|
72
|
+
*
|
|
73
|
+
* `env` names the variables that would satisfy the same requirement without
|
|
74
|
+
* the interactive command, so an agent that cannot show a prompt still knows
|
|
75
|
+
* what the person has to provide - never their values.
|
|
67
76
|
*/
|
|
68
77
|
nextAction?: {
|
|
69
78
|
type: "authenticate" | "select_project" | "configure_runtime";
|
|
70
79
|
command?: string;
|
|
80
|
+
userCommand?: string;
|
|
81
|
+
env?: string[];
|
|
71
82
|
};
|
|
72
83
|
project?: {
|
|
73
84
|
root: string;
|
|
@@ -113,7 +113,16 @@ function finish(changes, state, project) {
|
|
|
113
113
|
code: "JAM_AUTH_REQUIRED",
|
|
114
114
|
changes,
|
|
115
115
|
requiresUserAction: true,
|
|
116
|
-
nextAction: {
|
|
116
|
+
nextAction: {
|
|
117
|
+
type: "authenticate",
|
|
118
|
+
// Deliberately no `command`: an agent must not run the login, and the
|
|
119
|
+
// absence is what stops it. `userCommand` is what it hands the person
|
|
120
|
+
// instead - previously that instruction existed only in CLI prose, so
|
|
121
|
+
// an agent reading the JSON alone knew a human was needed but not for
|
|
122
|
+
// what.
|
|
123
|
+
userCommand: portableBootstrapCommand("auth login"),
|
|
124
|
+
env: ["JIRA_BASE_URL", "JIRA_EMAIL", "JIRA_API_TOKEN"],
|
|
125
|
+
},
|
|
117
126
|
project,
|
|
118
127
|
};
|
|
119
128
|
}
|
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/config/schema.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export declare const ProjectConfigSchema: z.ZodObject<{
|
|
|
19
19
|
customFields: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
20
20
|
id: z.ZodString;
|
|
21
21
|
name: z.ZodString;
|
|
22
|
+
writable: z.ZodDefault<z.ZodBoolean>;
|
|
22
23
|
}, z.core.$strip>>>;
|
|
23
24
|
output: z.ZodPrefault<z.ZodObject<{
|
|
24
25
|
searchTokens: z.ZodDefault<z.ZodNumber>;
|
package/dist/config/schema.js
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
/** Values that appear more than once, each named once. */
|
|
3
|
+
function duplicates(values) {
|
|
4
|
+
const seen = new Set();
|
|
5
|
+
const repeated = new Set();
|
|
6
|
+
for (const value of values) {
|
|
7
|
+
if (seen.has(value))
|
|
8
|
+
repeated.add(value);
|
|
9
|
+
seen.add(value);
|
|
10
|
+
}
|
|
11
|
+
return [...repeated];
|
|
12
|
+
}
|
|
13
|
+
function report(ctx, repeated, what) {
|
|
14
|
+
for (const value of repeated) {
|
|
15
|
+
ctx.addIssue({
|
|
16
|
+
code: "custom",
|
|
17
|
+
message: `duplicate custom field ${what} "${value}" - a selector must name one field`,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
2
21
|
/**
|
|
3
22
|
* `.jira-agent/project.yaml` - per-project policy only.
|
|
4
23
|
* Credentials are never stored here; they come from the CredentialPort.
|
|
@@ -41,13 +60,28 @@ export const ProjectConfigSchema = z.object({
|
|
|
41
60
|
/**
|
|
42
61
|
* Whitelisted project-specific custom fields, surfaced at CONTEXT level and up.
|
|
43
62
|
* `id` is the Jira field id (customfield_10011); `name` is what the agent sees.
|
|
63
|
+
*
|
|
64
|
+
* `writable` is a second, separate consent. Reading a field and letting an
|
|
65
|
+
* agent change it are different decisions, and a config written when JAM
|
|
66
|
+
* could only read must not start granting writes because JAM learned how.
|
|
67
|
+
* So it defaults to false: every whitelist that predates this is read-only,
|
|
68
|
+
* and a team opts a field in by saying so.
|
|
44
69
|
*/
|
|
45
70
|
customFields: z
|
|
46
71
|
.array(z.object({
|
|
47
72
|
id: z.string().regex(/^customfield_\d+$/),
|
|
48
73
|
name: z.string().min(1),
|
|
74
|
+
writable: z.boolean().default(false),
|
|
49
75
|
}))
|
|
50
|
-
.default([])
|
|
76
|
+
.default([])
|
|
77
|
+
.superRefine((fields, ctx) => {
|
|
78
|
+
// Ambiguity in a whitelist is worse than an omission: `custom-field.update`
|
|
79
|
+
// resolves a selector against these entries, and two rows answering to
|
|
80
|
+
// the same selector would make which field gets written a matter of
|
|
81
|
+
// ordering.
|
|
82
|
+
report(ctx, duplicates(fields.map((f) => f.id.toLowerCase())), "id");
|
|
83
|
+
report(ctx, duplicates(fields.filter((f) => f.writable).map((f) => f.name.trim().toLowerCase())), "writable name");
|
|
84
|
+
}),
|
|
51
85
|
output: z
|
|
52
86
|
.object({
|
|
53
87
|
/** Rough token ceilings per level. Enforced by OutputBudgetPolicy. */
|
package/dist/deps.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { CredentialPort } from "./ports/credentials.port.js";
|
|
|
6
6
|
import type { JiraReadPort } from "./ports/jira-read.port.js";
|
|
7
7
|
import type { JiraAssigneeResolutionPort } from "./ports/jira-assignee-resolution.port.js";
|
|
8
8
|
import type { JiraCreateMetadataPort } from "./ports/jira-create-metadata.port.js";
|
|
9
|
+
import type { JiraEditMetadataPort } from "./ports/jira-edit-metadata.port.js";
|
|
9
10
|
import type { JiraWritePort } from "./ports/jira-write.port.js";
|
|
10
11
|
import { WritePlanStore } from "./application/write-plan-store.js";
|
|
11
12
|
import type { TelemetryPort } from "./ports/telemetry.port.js";
|
|
@@ -36,6 +37,12 @@ export type JamDeps = {
|
|
|
36
37
|
* it mutates nothing.
|
|
37
38
|
*/
|
|
38
39
|
jiraAssignees: JiraAssigneeResolutionPort;
|
|
40
|
+
/**
|
|
41
|
+
* What Jira will let this account change on one issue. A fifth read-shaped
|
|
42
|
+
* port, for the same reason as the third and fourth: it mutates nothing, and
|
|
43
|
+
* it answers a question about a configuration rather than about an issue.
|
|
44
|
+
*/
|
|
45
|
+
jiraEditMetadata: JiraEditMetadataPort;
|
|
39
46
|
/**
|
|
40
47
|
* Plans awaiting apply. Lives for the life of this server process - see
|
|
41
48
|
* WritePlanStore for why it is not persisted.
|
|
@@ -55,6 +62,8 @@ export type BuildDepsOptions = {
|
|
|
55
62
|
jiraCreateMetadata?: JiraCreateMetadataPort;
|
|
56
63
|
/** Injected by tests so user resolution never reaches a real directory. */
|
|
57
64
|
jiraAssignees?: JiraAssigneeResolutionPort;
|
|
65
|
+
/** Injected by tests so edit metadata comes from a fixture, not a site. */
|
|
66
|
+
jiraEditMetadata?: JiraEditMetadataPort;
|
|
58
67
|
/** Injected by tests to bypass the real process/registry credential lookup. */
|
|
59
68
|
credentials?: CredentialPort;
|
|
60
69
|
/**
|
package/dist/deps.js
CHANGED
|
@@ -43,6 +43,11 @@ export async function buildDeps(options = {}) {
|
|
|
43
43
|
const { JiraCloudAssigneeResolutionAdapter } = await import("./adapters/jira-cloud/jira-assignee-resolution.adapter.js");
|
|
44
44
|
jiraAssignees = new JiraCloudAssigneeResolutionAdapter(credentials);
|
|
45
45
|
}
|
|
46
|
+
let jiraEditMetadata = options.jiraEditMetadata;
|
|
47
|
+
if (!jiraEditMetadata) {
|
|
48
|
+
const { JiraCloudEditMetadataAdapter } = await import("./adapters/jira-cloud/jira-edit-metadata.adapter.js");
|
|
49
|
+
jiraEditMetadata = new JiraCloudEditMetadataAdapter(credentials);
|
|
50
|
+
}
|
|
46
51
|
return {
|
|
47
52
|
config: resolved.config,
|
|
48
53
|
configPath: resolved.configPath,
|
|
@@ -51,6 +56,7 @@ export async function buildDeps(options = {}) {
|
|
|
51
56
|
jiraWrite,
|
|
52
57
|
jiraCreateMetadata,
|
|
53
58
|
jiraAssignees,
|
|
59
|
+
jiraEditMetadata,
|
|
54
60
|
writePlans: new WritePlanStore(),
|
|
55
61
|
cache: new NoopCache(),
|
|
56
62
|
telemetry,
|
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", "JAM_WRITE_SCOPE_VIOLATION", "JAM_WRITE_OPERATION_NOT_ALLOWED", "JAM_WRITE_FIELD_NOT_ALLOWED", "JAM_WRITE_TRANSITION_NOT_AVAILABLE", "JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE", "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED", "JAM_WRITE_VALUE_NOT_ALLOWED", "JAM_WRITE_SCHEMA_CHANGED", "JAM_WRITE_ASSIGNEE_NOT_FOUND", "JAM_WRITE_ASSIGNEE_AMBIGUOUS", "JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE", "JAM_WRITE_ASSIGNEE_ALREADY_SET", "JAM_WRITE_PLAN_NOT_FOUND", "JAM_WRITE_PLAN_EXPIRED", "JAM_WRITE_CONFLICT", "JAM_WRITE_VERIFICATION_FAILED", "JAM_WRITE_UNCERTAIN"];
|
|
8
|
+
export declare const JAM_ERROR_CODES: readonly ["JIRA_AUTH_FAILED", "JIRA_PERMISSION_DENIED", "JQL_INVALID", "ISSUE_NOT_FOUND", "RATE_LIMITED", "CONTEXT_TOO_LARGE", "PARTIAL_RESULT", "CONFIG_INVALID", "JIRA_UNAVAILABLE", "JAM_SETUP_REQUIRED", "JAM_BINDINGS_UNREADABLE", "JAM_WRITE_SCOPE_VIOLATION", "JAM_WRITE_OPERATION_NOT_ALLOWED", "JAM_WRITE_FIELD_NOT_ALLOWED", "JAM_WRITE_TRANSITION_NOT_AVAILABLE", "JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE", "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED", "JAM_WRITE_VALUE_NOT_ALLOWED", "JAM_WRITE_SCHEMA_CHANGED", "JAM_WRITE_ASSIGNEE_NOT_FOUND", "JAM_WRITE_ASSIGNEE_AMBIGUOUS", "JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE", "JAM_WRITE_ASSIGNEE_ALREADY_SET", "JAM_WRITE_CUSTOM_FIELD_NOT_EDITABLE", "JAM_WRITE_CUSTOM_FIELD_TYPE_UNSUPPORTED", "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
|
@@ -42,6 +42,12 @@ export const JAM_ERROR_CODES = [
|
|
|
42
42
|
"JAM_WRITE_ASSIGNEE_AMBIGUOUS",
|
|
43
43
|
"JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE",
|
|
44
44
|
"JAM_WRITE_ASSIGNEE_ALREADY_SET",
|
|
45
|
+
// Custom fields. Three permissions have to line up and none implies another,
|
|
46
|
+
// so a refusal says which one is missing: the team never opted this field in,
|
|
47
|
+
// Jira will not let it be set on this issue, or JAM does not know the type
|
|
48
|
+
// well enough to write it. Each points somewhere different.
|
|
49
|
+
"JAM_WRITE_CUSTOM_FIELD_NOT_EDITABLE",
|
|
50
|
+
"JAM_WRITE_CUSTOM_FIELD_TYPE_UNSUPPORTED",
|
|
45
51
|
"JAM_WRITE_PLAN_NOT_FOUND",
|
|
46
52
|
"JAM_WRITE_PLAN_EXPIRED",
|
|
47
53
|
"JAM_WRITE_CONFLICT",
|
package/dist/domain/write.d.ts
CHANGED
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
* layer: these name an issue, creation names a project; these compare a
|
|
21
21
|
* revision to detect a conflict, creation has no revision to compare.
|
|
22
22
|
*/
|
|
23
|
-
export declare const EXISTING_ISSUE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition", "assignee.update"];
|
|
23
|
+
export declare const EXISTING_ISSUE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition", "assignee.update", "custom-field.update"];
|
|
24
24
|
/** The operations the public MCP surface accepts. Nothing else is reachable. */
|
|
25
|
-
export declare const WRITE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition", "assignee.update", "issue.create"];
|
|
25
|
+
export declare const WRITE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition", "assignee.update", "custom-field.update", "issue.create"];
|
|
26
26
|
export type ExistingIssueOperation = (typeof EXISTING_ISSUE_OPERATIONS)[number];
|
|
27
27
|
export type WriteOperation = (typeof WRITE_OPERATIONS)[number];
|
|
28
28
|
/**
|
|
@@ -93,7 +93,7 @@ export type CreateIssueInput = {
|
|
|
93
93
|
labels?: string[];
|
|
94
94
|
components?: string[];
|
|
95
95
|
};
|
|
96
|
-
export type WriteInput = CommentAddInput | FieldUpdateInput | StatusTransitionInput | AssigneeUpdateInput | CreateIssueInput;
|
|
96
|
+
export type WriteInput = CommentAddInput | FieldUpdateInput | StatusTransitionInput | AssigneeUpdateInput | CustomFieldUpdateInput | CreateIssueInput;
|
|
97
97
|
/** An issue type as Jira offers it for one project, right now. */
|
|
98
98
|
export type CreateIssueType = {
|
|
99
99
|
id: string;
|
|
@@ -143,6 +143,93 @@ export type CreateSchemaRequirements = {
|
|
|
143
143
|
resolved: string;
|
|
144
144
|
}[];
|
|
145
145
|
};
|
|
146
|
+
/**
|
|
147
|
+
* One field on an issue's edit screen, as Jira describes it.
|
|
148
|
+
*
|
|
149
|
+
* Normalized at the adapter: `operations` and `schema` come straight from
|
|
150
|
+
* Jira's own vocabulary because they are the vocabulary the decision is made
|
|
151
|
+
* in, but nothing else of the raw document travels.
|
|
152
|
+
*/
|
|
153
|
+
export type EditFieldMetadata = {
|
|
154
|
+
/** Jira's field id, e.g. `customfield_10021`. */
|
|
155
|
+
id: string;
|
|
156
|
+
name: string;
|
|
157
|
+
required: boolean;
|
|
158
|
+
/** What Jira says can be done to this field: `set`, `add`, `remove`, ... */
|
|
159
|
+
operations: string[];
|
|
160
|
+
schema: {
|
|
161
|
+
type: string;
|
|
162
|
+
/** Element type, for `type: "array"`. */
|
|
163
|
+
items?: string;
|
|
164
|
+
/** The custom field's implementation key, when it is a custom field. */
|
|
165
|
+
custom?: string;
|
|
166
|
+
customId?: number;
|
|
167
|
+
};
|
|
168
|
+
/** Present only where Jira constrains the value. Absent is not empty. */
|
|
169
|
+
allowedValues?: EditFieldOption[];
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* One option Jira offers for a constrained field.
|
|
173
|
+
*
|
|
174
|
+
* `id` is the identity and `label` is what a person reads - the same split as
|
|
175
|
+
* a user's accountId and display name, and for the same reason: an option can
|
|
176
|
+
* be renamed without becoming a different option, and two options could carry
|
|
177
|
+
* the same label.
|
|
178
|
+
*/
|
|
179
|
+
export type EditFieldOption = {
|
|
180
|
+
id: string;
|
|
181
|
+
label: string;
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* The custom field value families JAM can write.
|
|
185
|
+
*
|
|
186
|
+
* Narrow on purpose. Each of these has an unambiguous wire shape that JAM can
|
|
187
|
+
* produce from a plain caller value and compare after the fact. Everything
|
|
188
|
+
* else - dates needing a timezone policy, rich text needing ADF, user and
|
|
189
|
+
* group pickers needing identity resolution, app-owned fields with private
|
|
190
|
+
* semantics - is refused rather than guessed at.
|
|
191
|
+
*/
|
|
192
|
+
export declare const CUSTOM_FIELD_KINDS: readonly ["text", "number", "single-option", "multi-option"];
|
|
193
|
+
export type CustomFieldKind = (typeof CUSTOM_FIELD_KINDS)[number];
|
|
194
|
+
export type CustomFieldUpdateInput = {
|
|
195
|
+
/** A configured field id, or a configured writable field name. */
|
|
196
|
+
field: string;
|
|
197
|
+
value: string | number | string[];
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* What a custom-field plan depends on, recorded so apply can check it again.
|
|
201
|
+
*
|
|
202
|
+
* The issue's revision does not cover any of this: a field can be taken off a
|
|
203
|
+
* screen, lose its `set` operation, change type, or have an option renamed
|
|
204
|
+
* without the issue itself being touched. So these premises are frozen
|
|
205
|
+
* alongside `baseUpdated`, and re-derived before the write.
|
|
206
|
+
*/
|
|
207
|
+
export type CustomFieldRequirements = {
|
|
208
|
+
fieldId: string;
|
|
209
|
+
fieldName: string;
|
|
210
|
+
kind: CustomFieldKind;
|
|
211
|
+
schema: {
|
|
212
|
+
type: string;
|
|
213
|
+
items?: string;
|
|
214
|
+
custom?: string;
|
|
215
|
+
};
|
|
216
|
+
/** Options resolved from Jira's allowed list, for the option kinds. */
|
|
217
|
+
resolvedOptions?: EditFieldOption[];
|
|
218
|
+
};
|
|
219
|
+
/** A custom field value as a receipt shows it - reviewable, not a Jira payload. */
|
|
220
|
+
export type CustomFieldValueView = {
|
|
221
|
+
id: string;
|
|
222
|
+
name: string;
|
|
223
|
+
value: string | number | null;
|
|
224
|
+
} | {
|
|
225
|
+
id: string;
|
|
226
|
+
name: string;
|
|
227
|
+
value: EditFieldOption | null;
|
|
228
|
+
} | {
|
|
229
|
+
id: string;
|
|
230
|
+
name: string;
|
|
231
|
+
value: EditFieldOption[];
|
|
232
|
+
};
|
|
146
233
|
/** A transition as Jira currently offers it for one issue. */
|
|
147
234
|
export type JiraTransition = {
|
|
148
235
|
id: string;
|
|
@@ -188,6 +275,11 @@ export type ExistingIssueWritePlan = WritePlanCommon & {
|
|
|
188
275
|
* `undefined` means the issue was unassigned.
|
|
189
276
|
*/
|
|
190
277
|
baseAssigneeAccountId?: string;
|
|
278
|
+
/**
|
|
279
|
+
* What this plan assumed about a custom field's configuration. Present only
|
|
280
|
+
* for `custom-field.update`; apply re-derives each premise before writing.
|
|
281
|
+
*/
|
|
282
|
+
customFieldRequirements?: CustomFieldRequirements;
|
|
191
283
|
};
|
|
192
284
|
/**
|
|
193
285
|
* A plan to create an issue that does not exist yet.
|
|
@@ -222,6 +314,10 @@ export type WriteMutation = {
|
|
|
222
314
|
} | {
|
|
223
315
|
kind: "assignee";
|
|
224
316
|
accountId: string;
|
|
317
|
+
} | {
|
|
318
|
+
kind: "custom-field";
|
|
319
|
+
fieldId: string;
|
|
320
|
+
value: unknown;
|
|
225
321
|
} | {
|
|
226
322
|
kind: "create";
|
|
227
323
|
fields: Record<string, unknown>;
|
package/dist/domain/write.js
CHANGED
|
@@ -25,6 +25,7 @@ export const EXISTING_ISSUE_OPERATIONS = [
|
|
|
25
25
|
"field.update",
|
|
26
26
|
"status.transition",
|
|
27
27
|
"assignee.update",
|
|
28
|
+
"custom-field.update",
|
|
28
29
|
];
|
|
29
30
|
/** The operations the public MCP surface accepts. Nothing else is reachable. */
|
|
30
31
|
export const WRITE_OPERATIONS = [...EXISTING_ISSUE_OPERATIONS, "issue.create"];
|
|
@@ -55,6 +56,16 @@ export const CREATABLE_FIELDS = [
|
|
|
55
56
|
"labels",
|
|
56
57
|
"components",
|
|
57
58
|
];
|
|
59
|
+
/**
|
|
60
|
+
* The custom field value families JAM can write.
|
|
61
|
+
*
|
|
62
|
+
* Narrow on purpose. Each of these has an unambiguous wire shape that JAM can
|
|
63
|
+
* produce from a plain caller value and compare after the fact. Everything
|
|
64
|
+
* else - dates needing a timezone policy, rich text needing ADF, user and
|
|
65
|
+
* group pickers needing identity resolution, app-owned fields with private
|
|
66
|
+
* semantics - is refused rather than guessed at.
|
|
67
|
+
*/
|
|
68
|
+
export const CUSTOM_FIELD_KINDS = ["text", "number", "single-option", "multi-option"];
|
|
58
69
|
export function isWriteOperation(value) {
|
|
59
70
|
return WRITE_OPERATIONS.includes(value);
|
|
60
71
|
}
|
package/dist/index.js
CHANGED
|
File without changes
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { applyWritePlan } from "../../application/apply-write.js";
|
|
3
3
|
import { runTool } from "../tool-result.js";
|
|
4
|
-
const DESCRIPTION = `Apply a plan from jira_write_plan. This changes Jira.
|
|
5
|
-
|
|
6
|
-
Takes a planId and nothing else. The change was decided when the plan was made, so there is no field, payload or override to pass here - that is deliberate, and it is what stops a write happening without the state check that planning did.
|
|
7
|
-
|
|
8
|
-
Before writing, JAM re-reads the issue and compares it to what the plan saw. If it moved, you get JAM_WRITE_CONFLICT and no write happens: call jira_write_plan again against the new state rather than treating the conflict as a transient failure.
|
|
9
|
-
|
|
10
|
-
After writing, JAM reads the issue back and checks the intended result is actually there. Only then does it return "applied". Jira accepting a request is not the same as the issue having changed.
|
|
11
|
-
|
|
12
|
-
Failures worth handling differently:
|
|
13
|
-
- JAM_WRITE_CONFLICT the issue moved; re-plan
|
|
14
|
-
- JAM_WRITE_PLAN_EXPIRED the plan aged out; re-plan
|
|
15
|
-
- JAM_WRITE_VERIFICATION_FAILED Jira accepted it but the issue does not show it; read the issue and tell the user
|
|
16
|
-
- JAM_WRITE_UNCERTAIN JAM does not know whether it landed; read the issue. Do NOT call this tool again - the write may already have been applied, and applying it twice is a second comment or a second transition.
|
|
17
|
-
|
|
4
|
+
const DESCRIPTION = `Apply a plan from jira_write_plan. This changes Jira.
|
|
5
|
+
|
|
6
|
+
Takes a planId and nothing else. The change was decided when the plan was made, so there is no field, payload or override to pass here - that is deliberate, and it is what stops a write happening without the state check that planning did.
|
|
7
|
+
|
|
8
|
+
Before writing, JAM re-reads the issue and compares it to what the plan saw. If it moved, you get JAM_WRITE_CONFLICT and no write happens: call jira_write_plan again against the new state rather than treating the conflict as a transient failure.
|
|
9
|
+
|
|
10
|
+
After writing, JAM reads the issue back and checks the intended result is actually there. Only then does it return "applied". Jira accepting a request is not the same as the issue having changed.
|
|
11
|
+
|
|
12
|
+
Failures worth handling differently:
|
|
13
|
+
- JAM_WRITE_CONFLICT the issue moved; re-plan
|
|
14
|
+
- JAM_WRITE_PLAN_EXPIRED the plan aged out; re-plan
|
|
15
|
+
- JAM_WRITE_VERIFICATION_FAILED Jira accepted it but the issue does not show it; read the issue and tell the user
|
|
16
|
+
- JAM_WRITE_UNCERTAIN JAM does not know whether it landed; read the issue. Do NOT call this tool again - the write may already have been applied, and applying it twice is a second comment or a second transition.
|
|
17
|
+
|
|
18
18
|
Never report an uncertain or unverified write as done.`;
|
|
19
19
|
export function registerJiraWriteApply(server, deps) {
|
|
20
20
|
server.registerTool("jira_write_apply", {
|