@jam-mcp/server 1.3.1 → 1.4.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/README.md +2 -2
- package/dist/adapters/credentials/secret-store.d.ts +7 -2
- package/dist/adapters/credentials/secret-store.js +62 -10
- package/dist/adapters/jira-cloud/jira-read.adapter.js +1 -11
- package/dist/application/apply-write.js +5 -52
- package/dist/application/plan-write.d.ts +2 -15
- package/dist/application/plan-write.js +6 -108
- package/dist/bootstrap/live-toolset.d.ts +36 -0
- package/dist/bootstrap/live-toolset.js +85 -0
- package/dist/bootstrap/mcp-config-merger.d.ts +11 -4
- package/dist/bootstrap/mcp-config-merger.js +10 -5
- package/dist/config/schema.d.ts +0 -1
- package/dist/config/schema.js +1 -35
- package/dist/deps.d.ts +0 -9
- package/dist/deps.js +0 -6
- package/dist/domain/errors.d.ts +1 -1
- package/dist/domain/errors.js +0 -6
- package/dist/domain/write.d.ts +3 -99
- package/dist/domain/write.js +0 -11
- package/dist/mcp/tools/jira-write-plan.tool.js +0 -12
- package/dist/ports/jira-read.port.d.ts +0 -10
- package/package.json +2 -2
- package/dist/adapters/jira-cloud/jira-edit-metadata.adapter.d.ts +0 -25
- package/dist/adapters/jira-cloud/jira-edit-metadata.adapter.js +0 -84
- package/dist/policy/custom-field-policy.d.ts +0 -93
- package/dist/policy/custom-field-policy.js +0 -230
- package/dist/ports/jira-edit-metadata.port.d.ts +0 -22
- package/dist/ports/jira-edit-metadata.port.js +0 -1
package/README.md
CHANGED
|
@@ -74,10 +74,10 @@ auth login Store Jira credentials in this user's OS secret store
|
|
|
74
74
|
runtime Show or change which JAM build this machine runs
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
Written out, that is `npx --yes @jam-mcp/launcher@1.
|
|
77
|
+
Written out, that is `npx --yes @jam-mcp/launcher@1.4.0 doctor`, or just `jam
|
|
78
78
|
doctor` if you took the launcher's optional global install. Starting from
|
|
79
79
|
nothing — no install, no runtime chosen yet — use
|
|
80
|
-
`npx --yes @jam-mcp/bootstrap@1.
|
|
80
|
+
`npx --yes @jam-mcp/bootstrap@1.4.0 init` instead.
|
|
81
81
|
|
|
82
82
|
Credentials come from the process environment or this user's OS secret store —
|
|
83
83
|
never from a repository file — and never appear in logs, telemetry, or tool
|
|
@@ -22,8 +22,13 @@ export type RunResult = {
|
|
|
22
22
|
stderr: string;
|
|
23
23
|
error?: NodeJS.ErrnoException;
|
|
24
24
|
};
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Injected by tests so the suite never touches a real keychain.
|
|
27
|
+
*
|
|
28
|
+
* `env` is merged over the inherited environment, and carries only values that
|
|
29
|
+
* are not secret - a file path, say. Secrets travel on stdin.
|
|
30
|
+
*/
|
|
31
|
+
export type RunFn = (command: string, args: string[], input?: string, env?: Record<string, string>) => RunResult;
|
|
27
32
|
export interface SecretStore {
|
|
28
33
|
/** Shown by `jam auth login`. Names the mechanism, never a value. */
|
|
29
34
|
readonly label: string;
|
|
@@ -30,10 +30,11 @@ export function secretStoreDisabled(env = process.env) {
|
|
|
30
30
|
function account() {
|
|
31
31
|
return userInfo().username;
|
|
32
32
|
}
|
|
33
|
-
function defaultRun(command, args, input) {
|
|
33
|
+
function defaultRun(command, args, input, env) {
|
|
34
34
|
const result = spawnSync(command, args, {
|
|
35
35
|
encoding: "utf8",
|
|
36
36
|
...(input === undefined ? {} : { input }),
|
|
37
|
+
...(env === undefined ? {} : { env: { ...process.env, ...env } }),
|
|
37
38
|
// No shell: arguments are passed as an array, so nothing is re-parsed.
|
|
38
39
|
windowsHide: true,
|
|
39
40
|
});
|
|
@@ -138,43 +139,94 @@ function linuxStore(run) {
|
|
|
138
139
|
* Kept separate from ~/.jam/config.yaml, which declares itself hand-editable
|
|
139
140
|
* and free of credentials.
|
|
140
141
|
*/
|
|
142
|
+
/**
|
|
143
|
+
* Both scripts need DPAPI, and both must survive a host that rewrote where
|
|
144
|
+
* PowerShell looks for modules.
|
|
145
|
+
*
|
|
146
|
+
* A CI runner does exactly that - it prepends its own paths, including ones
|
|
147
|
+
* belonging to a different PowerShell edition, and then Windows PowerShell 5.1
|
|
148
|
+
* either cannot resolve `ConvertTo-SecureString` at all or trips over type data
|
|
149
|
+
* from a module that was never meant for it. Neither failure says anything
|
|
150
|
+
* about credentials, so both look like a JAM bug to whoever reads them.
|
|
151
|
+
*
|
|
152
|
+
* So the child starts from the machine's own module path and asks for the
|
|
153
|
+
* module by name. This changes nothing outside that one short-lived process.
|
|
154
|
+
*/
|
|
155
|
+
const IMPORT_SECURITY = "$env:PSModulePath=[Environment]::GetEnvironmentVariable('PSModulePath','Machine');" +
|
|
156
|
+
"Import-Module Microsoft.PowerShell.Security -ErrorAction Stop;";
|
|
157
|
+
/**
|
|
158
|
+
* What the child writes, we read as UTF-8 - so say so before it writes anything.
|
|
159
|
+
*
|
|
160
|
+
* `spawnSync` is told `encoding: "utf8"`, but powershell.exe writes through the
|
|
161
|
+
* console code page, which on a Korean install is 949. The bytes and the decoder
|
|
162
|
+
* then disagree and an error message arrives as mojibake: the user is handed a
|
|
163
|
+
* failure they cannot even read. Setting the output encoding inside the child
|
|
164
|
+
* changes nothing outside it.
|
|
165
|
+
*/
|
|
166
|
+
const UTF8_OUTPUT = "[Console]::OutputEncoding=[Text.Encoding]::UTF8;" +
|
|
167
|
+
"$OutputEncoding=[Text.Encoding]::UTF8;";
|
|
168
|
+
/**
|
|
169
|
+
* Read stdin as UTF-8, by saying so on the stream rather than on the console.
|
|
170
|
+
*
|
|
171
|
+
* `[Console]::In` on Windows PowerShell 5.1 is already bound to the console
|
|
172
|
+
* input code page by the time a `-Command` script could change it, so a value
|
|
173
|
+
* with non-ASCII in it - a Jira account under a Korean name, say - arrived
|
|
174
|
+
* mangled and was then encrypted mangled. Opening the standard input stream
|
|
175
|
+
* with an explicit encoding sidesteps that entirely.
|
|
176
|
+
*/
|
|
177
|
+
const READ_STDIN_UTF8 = "$in=(New-Object IO.StreamReader(" +
|
|
178
|
+
"[Console]::OpenStandardInput(),[Text.Encoding]::UTF8)).ReadToEnd();";
|
|
141
179
|
function windowsStore(run) {
|
|
142
180
|
const dir = join(homedir(), ".jam");
|
|
143
181
|
const path = join(dir, "credentials.dpapi");
|
|
144
|
-
|
|
145
|
-
|
|
182
|
+
/**
|
|
183
|
+
* The path reaches PowerShell in an environment variable, never in argv and
|
|
184
|
+
* never interpolated into the script text.
|
|
185
|
+
*
|
|
186
|
+
* It used to ride as a trailing argument with `-args`, which does not work:
|
|
187
|
+
* `powershell.exe -Command` appends what follows to the command text rather
|
|
188
|
+
* than filling `$args` - that is `-File` semantics - so the script read
|
|
189
|
+
* `$args[0]` as `$null` and `Set-Content` refused the null path. Reading was
|
|
190
|
+
* broken the same way and failed quietly, since `Test-Path $null` is false.
|
|
191
|
+
*
|
|
192
|
+
* The variable holds a path, not a secret. The secret still reaches the
|
|
193
|
+
* process on stdin and appears nowhere else.
|
|
194
|
+
*/
|
|
195
|
+
const PATH_VAR = "JAM_SECRET_FILE";
|
|
146
196
|
const decrypt = [
|
|
147
197
|
"-NoProfile",
|
|
148
198
|
"-NonInteractive",
|
|
149
199
|
"-Command",
|
|
150
|
-
|
|
200
|
+
UTF8_OUTPUT +
|
|
201
|
+
IMPORT_SECURITY +
|
|
202
|
+
`$p=$env:${PATH_VAR}; if(!(Test-Path $p)){exit 1};` +
|
|
151
203
|
"$s=Get-Content $p -Raw | ConvertTo-SecureString;" +
|
|
152
204
|
"[Runtime.InteropServices.Marshal]::PtrToStringAuto(" +
|
|
153
205
|
"[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))",
|
|
154
|
-
"-args",
|
|
155
206
|
];
|
|
156
207
|
const encrypt = [
|
|
157
208
|
"-NoProfile",
|
|
158
209
|
"-NonInteractive",
|
|
159
210
|
"-Command",
|
|
160
|
-
|
|
211
|
+
UTF8_OUTPUT +
|
|
212
|
+
IMPORT_SECURITY +
|
|
213
|
+
READ_STDIN_UTF8 +
|
|
161
214
|
"$in | ConvertTo-SecureString -AsPlainText -Force |" +
|
|
162
|
-
|
|
163
|
-
"-args",
|
|
215
|
+
` ConvertFrom-SecureString | Set-Content $env:${PATH_VAR} -NoNewline`,
|
|
164
216
|
];
|
|
165
217
|
return {
|
|
166
218
|
label: "Windows DPAPI (user-encrypted file)",
|
|
167
219
|
read() {
|
|
168
220
|
if (!existsSync(path))
|
|
169
221
|
return undefined;
|
|
170
|
-
const res = run("powershell",
|
|
222
|
+
const res = run("powershell", decrypt, undefined, { [PATH_VAR]: path });
|
|
171
223
|
if (res.error || res.status !== 0)
|
|
172
224
|
return undefined;
|
|
173
225
|
return parse(res.stdout.trim());
|
|
174
226
|
},
|
|
175
227
|
write(values) {
|
|
176
228
|
mkdirSync(dir, { recursive: true });
|
|
177
|
-
const res = run("powershell",
|
|
229
|
+
const res = run("powershell", encrypt, JSON.stringify(values), { [PATH_VAR]: path });
|
|
178
230
|
if (res.error?.code === "ENOENT")
|
|
179
231
|
throw unavailable("powershell");
|
|
180
232
|
if (res.status !== 0)
|
|
@@ -45,21 +45,11 @@ export class JiraCloudReadAdapter {
|
|
|
45
45
|
// Read straight off the raw payload rather than through the mapper: the
|
|
46
46
|
// mapper's job is the shape the read tools see, and this identity is only
|
|
47
47
|
// for the write plane. Raw DTOs still stop here.
|
|
48
|
-
const
|
|
49
|
-
const assignee = raw?.["assignee"];
|
|
48
|
+
const assignee = data.fields?.assignee;
|
|
50
49
|
const accountId = typeof assignee?.accountId === "string" ? assignee.accountId : undefined;
|
|
51
|
-
// Only the ids that were asked for, and only when some were: a caller that
|
|
52
|
-
// did not request a custom field gets no entry rather than an empty object
|
|
53
|
-
// it has to tell apart from a field that is genuinely unset.
|
|
54
|
-
const customFieldValues = {};
|
|
55
|
-
for (const field of req.fields) {
|
|
56
|
-
if (field.startsWith("customfield_"))
|
|
57
|
-
customFieldValues[field] = raw?.[field] ?? null;
|
|
58
|
-
}
|
|
59
50
|
return {
|
|
60
51
|
issue: mapIssueWithMeta(data, this.config).issue,
|
|
61
52
|
...(accountId ? { assigneeAccountId: accountId } : {}),
|
|
62
|
-
...(Object.keys(customFieldValues).length > 0 ? { customFieldValues } : {}),
|
|
63
53
|
responseBytes: bytes,
|
|
64
54
|
};
|
|
65
55
|
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { JamError, toJamError } from "../domain/errors.js";
|
|
2
2
|
import { readModeAfterWrite } from "../policy/consistency-policy.js";
|
|
3
3
|
import { assertAssignable } from "../policy/assignee-policy.js";
|
|
4
|
-
import { assertCustomFieldUnchanged } from "../policy/custom-field-policy.js";
|
|
5
4
|
import { assertUnchanged } from "../policy/write-policy.js";
|
|
6
5
|
import { applyCreateIssue } from "./apply-create-issue.js";
|
|
7
|
-
import {
|
|
6
|
+
import { readIssue } from "./plan-write.js";
|
|
8
7
|
/**
|
|
9
8
|
* Execute a plan JAM made, then go and look at what happened.
|
|
10
9
|
*
|
|
@@ -63,18 +62,10 @@ export async function applyWritePlan(deps, request) {
|
|
|
63
62
|
* own state, which `assertUnchanged` already compared.
|
|
64
63
|
*/
|
|
65
64
|
async function revalidate(deps, plan) {
|
|
66
|
-
if (plan.mutation.kind
|
|
67
|
-
const target = plan.intendedAfter["assignee"];
|
|
68
|
-
assertAssignable(plan.issueKey, target, await deps.jiraAssignees.isAssignable(plan.issueKey, plan.mutation.accountId));
|
|
65
|
+
if (plan.mutation.kind !== "assignee")
|
|
69
66
|
return;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// A field can be taken off a screen, lose its `set` operation, change type
|
|
73
|
-
// or have an option renamed without the issue's own revision moving, so
|
|
74
|
-
// `assertUnchanged` cannot see any of it. These are the premises the plan
|
|
75
|
-
// actually rested on, re-derived.
|
|
76
|
-
assertCustomFieldUnchanged(plan.issueKey, plan.customFieldRequirements, await deps.jiraEditMetadata.getEditableFields(plan.issueKey));
|
|
77
|
-
}
|
|
67
|
+
const target = plan.intendedAfter["assignee"];
|
|
68
|
+
assertAssignable(plan.issueKey, target, await deps.jiraAssignees.isAssignable(plan.issueKey, plan.mutation.accountId));
|
|
78
69
|
}
|
|
79
70
|
/**
|
|
80
71
|
* Send the mutation, once.
|
|
@@ -101,14 +92,6 @@ async function mutate(deps, plan) {
|
|
|
101
92
|
case "assignee":
|
|
102
93
|
await deps.jiraWrite.assignIssue(plan.issueKey, plan.mutation.accountId);
|
|
103
94
|
return {};
|
|
104
|
-
case "custom-field":
|
|
105
|
-
// The ordinary issue edit endpoint. A custom field is a field; what
|
|
106
|
-
// made it need its own operation was deciding whether it may be
|
|
107
|
-
// written and in what shape, and that is already settled here.
|
|
108
|
-
await deps.jiraWrite.updateIssue(plan.issueKey, {
|
|
109
|
-
[plan.mutation.fieldId]: plan.mutation.value,
|
|
110
|
-
});
|
|
111
|
-
return {};
|
|
112
95
|
case "create":
|
|
113
96
|
// Unreachable: a create plan is routed to applyCreateIssue above. The
|
|
114
97
|
// case exists so adding a mutation kind is a compile error here rather
|
|
@@ -141,7 +124,7 @@ function isAmbiguous(err) {
|
|
|
141
124
|
* ours.
|
|
142
125
|
*/
|
|
143
126
|
async function verify(deps, plan) {
|
|
144
|
-
const snapshot = await readIssue(deps, plan.issueKey
|
|
127
|
+
const snapshot = await readIssue(deps, plan.issueKey);
|
|
145
128
|
const issue = snapshot.issue;
|
|
146
129
|
if (plan.mutation.kind === "assignee") {
|
|
147
130
|
// On the accountId, never on the display name. Two people can share a
|
|
@@ -173,15 +156,6 @@ async function verify(deps, plan) {
|
|
|
173
156
|
}
|
|
174
157
|
return { comments: comments.length, commentAdded: wanted };
|
|
175
158
|
}
|
|
176
|
-
if (plan.mutation.kind === "custom-field" && plan.customFieldRequirements) {
|
|
177
|
-
const requirements = plan.customFieldRequirements;
|
|
178
|
-
const expected = plan.intendedAfter["customField"];
|
|
179
|
-
const observedValue = currentCustomFieldView({ id: requirements.fieldId, name: requirements.fieldName }, requirements.kind, snapshot.customFieldValues?.[requirements.fieldId]);
|
|
180
|
-
if (!sameCustomFieldValue(requirements.kind, expected.value, observedValue.value)) {
|
|
181
|
-
throw verificationFailed(plan, { customField: expected }, { customField: observedValue });
|
|
182
|
-
}
|
|
183
|
-
return { customField: observedValue };
|
|
184
|
-
}
|
|
185
159
|
const observed = observedFor(plan, issue);
|
|
186
160
|
for (const [field, expected] of Object.entries(plan.intendedAfter)) {
|
|
187
161
|
if (!sameValue(observed[field], expected)) {
|
|
@@ -215,27 +189,6 @@ function observedFor(plan, issue) {
|
|
|
215
189
|
}
|
|
216
190
|
return observed;
|
|
217
191
|
}
|
|
218
|
-
/**
|
|
219
|
-
* Did the field end up holding what was planned?
|
|
220
|
-
*
|
|
221
|
-
* Options are compared on their ids, never on their labels - an option is
|
|
222
|
-
* identified by its id, and a label is what a person reads. For a multi-select
|
|
223
|
-
* the comparison is set-wise: Jira is free to return the same selection in a
|
|
224
|
-
* different order, and that is not a different selection.
|
|
225
|
-
*/
|
|
226
|
-
function sameCustomFieldValue(kind, expected, observed) {
|
|
227
|
-
if (kind === "multi-option") {
|
|
228
|
-
const ids = (v) => (Array.isArray(v) ? v.map((o) => o.id) : []).sort();
|
|
229
|
-
const a = ids(expected);
|
|
230
|
-
const b = ids(observed);
|
|
231
|
-
return a.length === b.length && a.every((id, i) => id === b[i]);
|
|
232
|
-
}
|
|
233
|
-
if (kind === "single-option") {
|
|
234
|
-
const id = (v) => v && typeof v === "object" && !Array.isArray(v) ? v.id : undefined;
|
|
235
|
-
return id(expected) === id(observed);
|
|
236
|
-
}
|
|
237
|
-
return expected === observed;
|
|
238
|
-
}
|
|
239
192
|
function sameValue(observed, expected) {
|
|
240
193
|
if (Array.isArray(expected) || Array.isArray(observed)) {
|
|
241
194
|
const a = Array.isArray(observed) ? [...observed].map(String).sort() : [];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { JamDeps } from "../deps.js";
|
|
2
2
|
import type { FullIssueContext } from "../domain/context.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type { WritePlan, WritePlanReceipt } from "../domain/write.js";
|
|
4
4
|
export type PlanWriteRequest = {
|
|
5
5
|
/** Absent for `issue.create`, which names a project rather than an issue. */
|
|
6
6
|
key?: string;
|
|
@@ -40,18 +40,5 @@ export type IssueSnapshot = {
|
|
|
40
40
|
issue: FullIssueContext;
|
|
41
41
|
/** Identity of the current assignee, which `issue.assignee` cannot supply. */
|
|
42
42
|
assigneeAccountId?: string;
|
|
43
|
-
/** Raw values for any custom field ids that were asked for. */
|
|
44
|
-
customFieldValues?: Record<string, unknown>;
|
|
45
43
|
};
|
|
46
|
-
export declare function readIssue(deps: JamDeps, issueKey: string
|
|
47
|
-
/**
|
|
48
|
-
* What the field holds now, in the shape a receipt shows.
|
|
49
|
-
*
|
|
50
|
-
* Jira stores an option as an object and a scalar as itself; a person reading
|
|
51
|
-
* `before` wants the same canonical form they will see in `intendedAfter`, so
|
|
52
|
-
* they can compare the two rather than a payload against a summary.
|
|
53
|
-
*/
|
|
54
|
-
export declare function currentCustomFieldView(field: {
|
|
55
|
-
id: string;
|
|
56
|
-
name: string;
|
|
57
|
-
}, kind: CustomFieldKind, raw: unknown): CustomFieldValueView;
|
|
44
|
+
export declare function readIssue(deps: JamDeps, issueKey: string): Promise<IssueSnapshot>;
|
|
@@ -1,7 +1,6 @@
|
|
|
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";
|
|
5
4
|
import { planCreateIssue } from "./plan-create-issue.js";
|
|
6
5
|
/**
|
|
7
6
|
* Work out whether a requested change is currently possible, and describe it.
|
|
@@ -32,15 +31,9 @@ export async function planWrite(deps, request) {
|
|
|
32
31
|
// does not write should get that answer, not a round trip and then that
|
|
33
32
|
// answer.
|
|
34
33
|
const input = validateInput(operation, request.input);
|
|
35
|
-
|
|
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] : []);
|
|
34
|
+
const snapshot = await readIssue(deps, issueKey);
|
|
42
35
|
const issue = snapshot.issue;
|
|
43
|
-
const { before, intendedAfter, mutation, transition, baseAssigneeAccountId
|
|
36
|
+
const { before, intendedAfter, mutation, transition, baseAssigneeAccountId } = await describe(deps, operation, issueKey, snapshot, input);
|
|
44
37
|
const createdAt = new Date();
|
|
45
38
|
const plan = deps.writePlans.create({
|
|
46
39
|
kind: "existing-issue",
|
|
@@ -54,7 +47,6 @@ export async function planWrite(deps, request) {
|
|
|
54
47
|
expiresAt: new Date(createdAt.getTime() + PLAN_TTL_MS).toISOString(),
|
|
55
48
|
...(transition ? { transition } : {}),
|
|
56
49
|
...(baseAssigneeAccountId ? { baseAssigneeAccountId } : {}),
|
|
57
|
-
...(customFieldRequirements ? { customFieldRequirements } : {}),
|
|
58
50
|
mutation,
|
|
59
51
|
});
|
|
60
52
|
return {
|
|
@@ -71,8 +63,8 @@ export async function planWrite(deps, request) {
|
|
|
71
63
|
},
|
|
72
64
|
};
|
|
73
65
|
}
|
|
74
|
-
export async function readIssue(deps, issueKey
|
|
75
|
-
const { issue: found, assigneeAccountId
|
|
66
|
+
export async function readIssue(deps, issueKey) {
|
|
67
|
+
const { issue: found, assigneeAccountId } = await deps.jira.getIssue({
|
|
76
68
|
key: issueKey,
|
|
77
69
|
// `issuetype` and `description` are here for creation's verification step,
|
|
78
70
|
// which has to confirm the issue Jira made is the one that was asked for.
|
|
@@ -89,33 +81,13 @@ export async function readIssue(deps, issueKey, extraFields = []) {
|
|
|
89
81
|
"labels",
|
|
90
82
|
"components",
|
|
91
83
|
"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)),
|
|
96
84
|
],
|
|
97
85
|
});
|
|
98
86
|
if (!found) {
|
|
99
87
|
throw new JamError("ISSUE_NOT_FOUND", `Jira has no issue ${issueKey}, or it is not visible to this account.`, { issueKey });
|
|
100
88
|
}
|
|
101
|
-
return {
|
|
102
|
-
issue: found,
|
|
103
|
-
...(assigneeAccountId ? { assigneeAccountId } : {}),
|
|
104
|
-
...(customFieldValues ? { customFieldValues } : {}),
|
|
105
|
-
};
|
|
89
|
+
return { issue: found, ...(assigneeAccountId ? { assigneeAccountId } : {}) };
|
|
106
90
|
}
|
|
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
|
-
]);
|
|
119
91
|
/**
|
|
120
92
|
* The issue an existing-issue operation names, or a refusal that says why.
|
|
121
93
|
*
|
|
@@ -156,30 +128,6 @@ function validateInput(operation, raw) {
|
|
|
156
128
|
}
|
|
157
129
|
return { status: status.trim() };
|
|
158
130
|
}
|
|
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
|
-
}
|
|
183
131
|
case "assignee.update": {
|
|
184
132
|
const assignee = raw.assignee;
|
|
185
133
|
if (typeof assignee !== "string" || assignee.trim().length === 0) {
|
|
@@ -189,7 +137,7 @@ function validateInput(operation, raw) {
|
|
|
189
137
|
}
|
|
190
138
|
}
|
|
191
139
|
}
|
|
192
|
-
async function describe(deps, operation, issueKey, snapshot, input
|
|
140
|
+
async function describe(deps, operation, issueKey, snapshot, input) {
|
|
193
141
|
const issue = snapshot.issue;
|
|
194
142
|
switch (operation) {
|
|
195
143
|
case "comment.add": {
|
|
@@ -230,31 +178,6 @@ async function describe(deps, operation, issueKey, snapshot, input, targetField)
|
|
|
230
178
|
transition,
|
|
231
179
|
};
|
|
232
180
|
}
|
|
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
|
-
}
|
|
258
181
|
case "assignee.update": {
|
|
259
182
|
const { assignee: requested } = input;
|
|
260
183
|
// Ask Jira who this is, and decide from what it says. The requested
|
|
@@ -317,31 +240,6 @@ function currentValue(issue, field) {
|
|
|
317
240
|
return undefined;
|
|
318
241
|
}
|
|
319
242
|
}
|
|
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
|
-
}
|
|
345
243
|
/** Whitelisted values to the shapes Jira's field API expects. */
|
|
346
244
|
function toJiraFields(input) {
|
|
347
245
|
const fields = {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the registered entry actually serves.
|
|
3
|
+
*
|
|
4
|
+
* Counting the tools of the process doing the counting proves nothing about
|
|
5
|
+
* the agent's experience: the agent talks to whatever the host registration
|
|
6
|
+
* launches, which may be an older release with a different tool set. This asks
|
|
7
|
+
* that process directly, over the protocol the agent uses.
|
|
8
|
+
*/
|
|
9
|
+
export type LiveToolsetVerdict = "OK" | "LIVE_TOOLSET_MISMATCH" | "UNREACHABLE";
|
|
10
|
+
export type LiveToolsetResult = {
|
|
11
|
+
verdict: LiveToolsetVerdict;
|
|
12
|
+
expected: string[];
|
|
13
|
+
actual?: string[];
|
|
14
|
+
missing?: string[];
|
|
15
|
+
detail?: string;
|
|
16
|
+
};
|
|
17
|
+
export type ToolsetProbe = (argv: {
|
|
18
|
+
command: string;
|
|
19
|
+
args: string[];
|
|
20
|
+
}) => Promise<string[] | null>;
|
|
21
|
+
export declare const expectedTools: () => string[];
|
|
22
|
+
/**
|
|
23
|
+
* Speak just enough MCP to ask for the tool list: initialize, initialized,
|
|
24
|
+
* tools/list. A full client would pull in the SDK's transport machinery for
|
|
25
|
+
* one question that is three lines of JSON.
|
|
26
|
+
*/
|
|
27
|
+
export declare const defaultToolsetProbe: ToolsetProbe;
|
|
28
|
+
/**
|
|
29
|
+
* Compare what the registered command serves against what this release
|
|
30
|
+
* defines. A tool the agent cannot see is a tool it does not have, whatever
|
|
31
|
+
* the package on disk says.
|
|
32
|
+
*/
|
|
33
|
+
export declare function checkLiveToolset(argv: {
|
|
34
|
+
command: string;
|
|
35
|
+
args: string[];
|
|
36
|
+
}, probe?: ToolsetProbe): Promise<LiveToolsetResult>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { SERVER_VERSION } from "@jam-mcp/launcher";
|
|
3
|
+
import { TOOL_NAMES } from "../mcp/create-server.js";
|
|
4
|
+
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
5
|
+
export const expectedTools = () => [...TOOL_NAMES].sort();
|
|
6
|
+
/**
|
|
7
|
+
* Speak just enough MCP to ask for the tool list: initialize, initialized,
|
|
8
|
+
* tools/list. A full client would pull in the SDK's transport machinery for
|
|
9
|
+
* one question that is three lines of JSON.
|
|
10
|
+
*/
|
|
11
|
+
export const defaultToolsetProbe = ({ command, args }) => new Promise((resolve) => {
|
|
12
|
+
const child = spawn(command, args, {
|
|
13
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
14
|
+
shell: process.platform === "win32",
|
|
15
|
+
});
|
|
16
|
+
let buffer = "";
|
|
17
|
+
let settled = false;
|
|
18
|
+
const done = (value) => {
|
|
19
|
+
if (settled)
|
|
20
|
+
return;
|
|
21
|
+
settled = true;
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
child.stdin.end();
|
|
24
|
+
child.kill();
|
|
25
|
+
resolve(value);
|
|
26
|
+
};
|
|
27
|
+
const timer = setTimeout(() => done(null), HANDSHAKE_TIMEOUT_MS);
|
|
28
|
+
child.on("error", () => done(null));
|
|
29
|
+
child.on("exit", () => done(null));
|
|
30
|
+
child.stdout.on("data", (chunk) => {
|
|
31
|
+
buffer += chunk.toString("utf8");
|
|
32
|
+
let newline = buffer.indexOf("\n");
|
|
33
|
+
while (newline >= 0) {
|
|
34
|
+
const line = buffer.slice(0, newline).trim();
|
|
35
|
+
buffer = buffer.slice(newline + 1);
|
|
36
|
+
newline = buffer.indexOf("\n");
|
|
37
|
+
if (!line)
|
|
38
|
+
continue;
|
|
39
|
+
try {
|
|
40
|
+
const message = JSON.parse(line);
|
|
41
|
+
if (message.id === 2) {
|
|
42
|
+
done((message.result?.tools ?? []).map((tool) => tool.name).sort());
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Not our line. The server owns stdout for the protocol; anything
|
|
48
|
+
// unparseable is noise from a wrapper and is skipped rather than
|
|
49
|
+
// treated as a failure.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const send = (payload) => {
|
|
54
|
+
child.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
55
|
+
};
|
|
56
|
+
send({
|
|
57
|
+
jsonrpc: "2.0",
|
|
58
|
+
id: 1,
|
|
59
|
+
method: "initialize",
|
|
60
|
+
params: {
|
|
61
|
+
protocolVersion: "2024-11-05",
|
|
62
|
+
capabilities: {},
|
|
63
|
+
clientInfo: { name: "jam-doctor", version: SERVER_VERSION },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
67
|
+
send({ jsonrpc: "2.0", id: 2, method: "tools/list" });
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* Compare what the registered command serves against what this release
|
|
71
|
+
* defines. A tool the agent cannot see is a tool it does not have, whatever
|
|
72
|
+
* the package on disk says.
|
|
73
|
+
*/
|
|
74
|
+
export async function checkLiveToolset(argv, probe = defaultToolsetProbe) {
|
|
75
|
+
const expected = expectedTools();
|
|
76
|
+
const actual = await probe(argv).catch(() => null);
|
|
77
|
+
if (actual === null) {
|
|
78
|
+
return { verdict: "UNREACHABLE", expected, detail: "the registered command did not answer tools/list" };
|
|
79
|
+
}
|
|
80
|
+
const missing = expected.filter((name) => !actual.includes(name));
|
|
81
|
+
if (missing.length > 0) {
|
|
82
|
+
return { verdict: "LIVE_TOOLSET_MISMATCH", expected, actual, missing };
|
|
83
|
+
}
|
|
84
|
+
return { verdict: "OK", expected, actual };
|
|
85
|
+
}
|
|
@@ -13,12 +13,19 @@ 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.
|
|
16
|
+
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.4.0", "serve"];
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
|
-
* Recognise wiring from before the launcher existed: a hard-coded path
|
|
20
|
-
* machine's checkout
|
|
21
|
-
*
|
|
19
|
+
* Recognise wiring from before the launcher existed: a hard-coded `node` path
|
|
20
|
+
* to one machine's checkout. That works only where it was written, which is
|
|
21
|
+
* why `--migrate` exists.
|
|
22
|
+
*
|
|
23
|
+
* A bare `jam` is deliberately NOT legacy any more. It is what a persistent
|
|
24
|
+
* install (`npm install -g @jam-mcp/launcher@<exact>`) provides, and on a
|
|
25
|
+
* machine whose package runner is broken it is the entry that still works —
|
|
26
|
+
* a real Windows npm was seen failing to start `npx` children at all. Someone
|
|
27
|
+
* who registered it chose it; `--migrate` must not silently rewrite that
|
|
28
|
+
* choice back into the very path that fails there.
|
|
22
29
|
*/
|
|
23
30
|
export declare function isLegacyJamEntry(entry: unknown): boolean;
|
|
24
31
|
export type McpMergeResult = {
|
|
@@ -18,16 +18,21 @@ export const JAM_MCP_ENTRY = {
|
|
|
18
18
|
args: ["--yes", LAUNCHER_PACKAGE_SPEC, "serve"],
|
|
19
19
|
};
|
|
20
20
|
/**
|
|
21
|
-
* Recognise wiring from before the launcher existed: a hard-coded path
|
|
22
|
-
* machine's checkout
|
|
23
|
-
*
|
|
21
|
+
* Recognise wiring from before the launcher existed: a hard-coded `node` path
|
|
22
|
+
* to one machine's checkout. That works only where it was written, which is
|
|
23
|
+
* why `--migrate` exists.
|
|
24
|
+
*
|
|
25
|
+
* A bare `jam` is deliberately NOT legacy any more. It is what a persistent
|
|
26
|
+
* install (`npm install -g @jam-mcp/launcher@<exact>`) provides, and on a
|
|
27
|
+
* machine whose package runner is broken it is the entry that still works —
|
|
28
|
+
* a real Windows npm was seen failing to start `npx` children at all. Someone
|
|
29
|
+
* who registered it chose it; `--migrate` must not silently rewrite that
|
|
30
|
+
* choice back into the very path that fails there.
|
|
24
31
|
*/
|
|
25
32
|
export function isLegacyJamEntry(entry) {
|
|
26
33
|
if (!entry || typeof entry !== "object")
|
|
27
34
|
return false;
|
|
28
35
|
const { command, args } = entry;
|
|
29
|
-
if (command === "jam")
|
|
30
|
-
return true;
|
|
31
36
|
if (command === "node")
|
|
32
37
|
return true;
|
|
33
38
|
if (command === "npx" && Array.isArray(args)) {
|