@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 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.3.1 doctor`, or just `jam
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.3.1 init` instead.
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
- /** Injected by tests so the suite never touches a real keychain. */
26
- export type RunFn = (command: string, args: string[], input?: string) => RunResult;
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
- // The path reaches PowerShell as an argument, never interpolated into the
145
- // script text; the secret reaches it on stdin.
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
- "$p=$args[0]; if(!(Test-Path $p)){exit 1};" +
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
- "$in=[Console]::In.ReadToEnd();" +
211
+ UTF8_OUTPUT +
212
+ IMPORT_SECURITY +
213
+ READ_STDIN_UTF8 +
161
214
  "$in | ConvertTo-SecureString -AsPlainText -Force |" +
162
- " ConvertFrom-SecureString | Set-Content $args[0] -NoNewline",
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", [...decrypt, path]);
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", [...encrypt, path], JSON.stringify(values));
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 raw = data.fields;
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 { currentCustomFieldView, readIssue } from "./plan-write.js";
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 === "assignee") {
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
- if (plan.mutation.kind === "custom-field" && plan.customFieldRequirements) {
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, plan.mutation.kind === "custom-field" ? [plan.mutation.fieldId] : []);
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 { CustomFieldKind, CustomFieldValueView, WritePlan, WritePlanReceipt } from "../domain/write.js";
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, extraFields?: string[]): Promise<IssueSnapshot>;
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
- // 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] : []);
34
+ const snapshot = await readIssue(deps, issueKey);
42
35
  const issue = snapshot.issue;
43
- const { before, intendedAfter, mutation, transition, baseAssigneeAccountId, customFieldRequirements } = await describe(deps, operation, issueKey, snapshot, input, targetField);
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, extraFields = []) {
75
- const { issue: found, assigneeAccountId, customFieldValues } = await deps.jira.getIssue({
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, targetField) {
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.3.1", "serve"];
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 to one
20
- * machine's checkout, or a bare `jam` that depends on a global PATH install.
21
- * Both work only where they were written, which is why `--migrate` exists.
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 to one
22
- * machine's checkout, or a bare `jam` that depends on a global PATH install.
23
- * Both work only where they were written, which is why `--migrate` exists.
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)) {