@nimbus-dev/connectors 0.5.0 → 0.6.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.
@@ -5,6 +5,8 @@ import {
5
5
  type WriteToolConfig,
6
6
  } from "../../../shared/consent-kit.ts";
7
7
  import { optionalBaseUrl } from "../../../shared/env-json-api.ts";
8
+ import { isSafeGitRef } from "../../../shared/github-rest.ts";
9
+ import { guardedFetch } from "../../../shared/guarded-fetch.ts";
8
10
  import {
9
11
  createRegisterSimpleTool,
10
12
  createZodToolRegistrar,
@@ -20,6 +22,14 @@ import {
20
22
  toRestFetchResult,
21
23
  } from "../../../shared/rest-tool-kit.ts";
22
24
 
25
+ /**
26
+ * A project reference that reaches a URL path: a numeric id, or `/`-separated segments of
27
+ * `[A-Za-z0-9._-]`, none of them `.` or `..` (a URL parser collapses those out of the path).
28
+ */
29
+ function isSafeProjectPath(p: string): boolean {
30
+ return p.split("/").every((seg) => /^[A-Za-z0-9._-]+$/.test(seg) && seg !== "." && seg !== "..");
31
+ }
32
+
23
33
  function apiBase(): string {
24
34
  return optionalBaseUrl("GITLAB_API_BASE_URL", "https://gitlab.com/api/v4");
25
35
  }
@@ -33,10 +43,13 @@ async function glFetch(token: string, path: string, init?: RequestInit): Promise
33
43
  init?.headers === undefined
34
44
  ? baseHeaders
35
45
  : { ...baseHeaders, ...(init.headers as Record<string, string>) };
36
- const res = await fetch(url, {
37
- ...init,
38
- headers: mergedHeaders,
39
- });
46
+ // guardedFetch: the PRIVATE-TOKEN header must never follow a redirect to another origin.
47
+ const res = await guardedFetch(
48
+ "GitLab",
49
+ url,
50
+ { ...init, headers: mergedHeaders },
51
+ { requireSecureBase: false },
52
+ );
40
53
  return toRestFetchResult(res);
41
54
  }
42
55
 
@@ -113,7 +126,12 @@ async function fetchJobTrace(parsed: {
113
126
  const token = requireProcessEnv("GITLAB_PAT");
114
127
  const enc = encodeURIComponent(parsed.projectPath);
115
128
  const url = `${apiBase()}/projects/${enc}/jobs/${String(parsed.jobId)}/trace`;
116
- const res = await fetch(url, { headers: { "PRIVATE-TOKEN": token } });
129
+ const res = await guardedFetch(
130
+ "GitLab",
131
+ url,
132
+ { headers: { "PRIVATE-TOKEN": token } },
133
+ { requireSecureBase: false },
134
+ );
117
135
  const text = await res.text();
118
136
  if (!res.ok) {
119
137
  throw new Error(`GitLab ${String(res.status)}: ${text.slice(0, 300)}`);
@@ -135,6 +153,9 @@ export const GITLAB_TOOL_NAMES = [
135
153
  "gitlab_issue_labels_add",
136
154
  "gitlab_issue_assignees_add",
137
155
  "gitlab_issue_state_set",
156
+ "gitlab_branch_create",
157
+ "gitlab_mr_create",
158
+ "gitlab_mr_update",
138
159
  "gitlab_pipeline_list",
139
160
  "gitlab_pipeline_get",
140
161
  "gitlab_pipeline_jobs_get",
@@ -197,7 +218,7 @@ export function registerGitlabTools(
197
218
  const projectPathArg = z.object({
198
219
  projectPath: z
199
220
  .string()
200
- .min(1)
221
+ .refine(isSafeProjectPath, "not a valid project path or numeric id")
201
222
  .describe("URL-encoded path or numeric project id, e.g. group/repo"),
202
223
  });
203
224
 
@@ -244,11 +265,14 @@ export function registerGitlabTools(
244
265
  `/projects/${encodeURIComponent(parsed.projectPath)}/merge_requests/${String(parsed.mergeRequestIid)}`,
245
266
  );
246
267
 
268
+ const FULL_SHA = /^[0-9a-f]{40}$/i;
269
+
247
270
  const gitlabMrMergeSchema = projectPathArg.extend({
248
271
  mergeRequestIid: z.number().int().min(1),
249
272
  mergeCommitMessage: z.string().max(10_000).optional(),
250
273
  squash: z.boolean().optional(),
251
274
  shouldRemoveSourceBranch: z.boolean().optional(),
275
+ sha: z.string().regex(FULL_SHA, "a full 40-hex commit sha").optional(),
252
276
  });
253
277
 
254
278
  registerGitlabWriteTool(
@@ -257,7 +281,7 @@ export function registerGitlabTools(
257
281
  mutates: "gitlab.mr.merge",
258
282
  recoverable: true,
259
283
  },
260
- "Merge a merge request.",
284
+ "Merge a merge request. If `sha` (40 hex) is given it is sent as the API's `sha` parameter, and GitLab fails the merge when the source branch head is no longer that commit (https://docs.gitlab.com/api/merge_requests/#merge-a-merge-request).",
261
285
  gitlabMrMergeSchema,
262
286
  (parsed) =>
263
287
  `/projects/${encodeURIComponent(parsed.projectPath)}/merge_requests/${String(parsed.mergeRequestIid)}/merge`,
@@ -272,6 +296,9 @@ export function registerGitlabTools(
272
296
  if (parsed.shouldRemoveSourceBranch !== undefined) {
273
297
  body["should_remove_source_branch"] = parsed.shouldRemoveSourceBranch;
274
298
  }
299
+ if (parsed.sha !== undefined) {
300
+ body["sha"] = parsed.sha;
301
+ }
275
302
  return {
276
303
  method: "PUT",
277
304
  headers: { "Content-Type": "application/json" },
@@ -388,18 +415,23 @@ export function registerGitlabTools(
388
415
  // header), so this one request bypasses glFetch. Never the private_token query parameter:
389
416
  // a token in a URL is logged.
390
417
  const res = await toRestFetchResult(
391
- await fetch(graphqlUrl(), {
392
- method: "POST",
393
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
394
- body: JSON.stringify({
395
- query: SET_ASSIGNEES_APPEND,
396
- variables: {
397
- projectPath: parsed.projectPath,
398
- iid: String(parsed.issueIid),
399
- assigneeUsernames: parsed.assigneeUsernames,
400
- },
401
- }),
402
- }),
418
+ await guardedFetch(
419
+ "GitLab",
420
+ graphqlUrl(),
421
+ {
422
+ method: "POST",
423
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
424
+ body: JSON.stringify({
425
+ query: SET_ASSIGNEES_APPEND,
426
+ variables: {
427
+ projectPath: parsed.projectPath,
428
+ iid: String(parsed.issueIid),
429
+ assigneeUsernames: parsed.assigneeUsernames,
430
+ },
431
+ }),
432
+ },
433
+ { requireSecureBase: false },
434
+ ),
403
435
  );
404
436
  if (!res.ok) {
405
437
  throw new Error(`GitLab ${String(res.status)}: ${res.text.slice(0, 300)}`);
@@ -421,6 +453,141 @@ export function registerGitlabTools(
421
453
  (parsed) => jsonBody("PUT", { state_event: parsed.stateEvent }),
422
454
  );
423
455
 
456
+ /**
457
+ * A branch or ref name placed in a URL and sent to GitLab: the same rule as GitHub's
458
+ * (`isSafeGitRef`, ASCII only, so no format, control or surrogate character can pass).
459
+ */
460
+ const gitlabRefSchema = z
461
+ .string()
462
+ .min(1)
463
+ .max(255)
464
+ .refine(isSafeGitRef, "not a valid branch or ref name");
465
+
466
+ /**
467
+ * A branch name that goes in a JSON body: GitLab's own rules apply, but a format (invisible or
468
+ * bidi), control or lone-surrogate character is refused, so an approval prompt shows what is sent.
469
+ */
470
+ const branchNameSchema = z
471
+ .string()
472
+ .min(1)
473
+ .max(255)
474
+ .refine(
475
+ (v) => !/[\p{Cf}\p{Cc}\p{Cs}]/u.test(v),
476
+ "branch name has an invisible or control character",
477
+ );
478
+
479
+ registerWriteTool(
480
+ "gitlab_branch_create",
481
+ // A new branch is undone by deleting it, so nothing needs capturing first.
482
+ {
483
+ mutates: "gitlab.branch.create",
484
+ recoverable: true,
485
+ scopeTargetOf: (p) => ({ kind: "repo", value: p.projectPath }),
486
+ },
487
+ "Create a branch from a branch, tag or commit (fromRef). If expectedSha (40 hex) is given, fromRef is first resolved to a commit: when that commit is not expectedSha nothing is created, otherwise the branch is created from exactly that commit.",
488
+ projectPathArg.extend({
489
+ branch: gitlabRefSchema,
490
+ fromRef: gitlabRefSchema,
491
+ expectedSha: z.string().regex(FULL_SHA).optional(),
492
+ }),
493
+ async (parsed) => {
494
+ const token = requireProcessEnv("GITLAB_PAT");
495
+ const project = encodeURIComponent(parsed.projectPath);
496
+ let ref = parsed.fromRef;
497
+ if (parsed.expectedSha !== undefined) {
498
+ // https://docs.gitlab.com/api/commits/#get-a-single-commit — `sha` is a hash or a branch or
499
+ // tag name; the response's `id` is the full commit hash.
500
+ const commit = await glFetch(
501
+ token,
502
+ `/projects/${project}/repository/commits/${encodeURIComponent(parsed.fromRef)}`,
503
+ );
504
+ const id = (commit.json as { id?: unknown } | null)?.id;
505
+ if (!commit.ok || typeof id !== "string" || !FULL_SHA.test(id)) {
506
+ throw new Error(`GitLab ${String(commit.status)}: ${commit.text.slice(0, 300)}`);
507
+ }
508
+ if (id.toLowerCase() !== parsed.expectedSha.toLowerCase()) {
509
+ throw new Error(`fromRef resolves to ${id}, not the approved ${parsed.expectedSha}`);
510
+ }
511
+ ref = id.toLowerCase();
512
+ }
513
+ const res = await glFetch(
514
+ token,
515
+ listUrl(`/projects/${project}/repository/branches`, (q) => {
516
+ q.set("branch", parsed.branch);
517
+ q.set("ref", ref);
518
+ }),
519
+ { method: "POST" },
520
+ );
521
+ return mcpJsonResultIfOk("GitLab", res);
522
+ },
523
+ );
524
+
525
+ registerGitlabWriteTool(
526
+ "gitlab_mr_create",
527
+ { mutates: "gitlab.mr.create", recoverable: true },
528
+ "Open a merge request from sourceBranch into targetBranch. Title and description are sent exactly as given.",
529
+ projectPathArg.extend({
530
+ sourceBranch: branchNameSchema,
531
+ targetBranch: branchNameSchema,
532
+ title: z.string().min(1).max(255),
533
+ description: z.string().max(65_000).optional(),
534
+ }),
535
+ (parsed) => `/projects/${encodeURIComponent(parsed.projectPath)}/merge_requests`,
536
+ (parsed) =>
537
+ jsonBody("POST", {
538
+ source_branch: parsed.sourceBranch,
539
+ target_branch: parsed.targetBranch,
540
+ title: parsed.title,
541
+ ...(parsed.description === undefined ? {} : { description: parsed.description }),
542
+ }),
543
+ );
544
+
545
+ // A plain object, not a `.refine()`d one: the registrar passes `.shape` as the MCP inputSchema and a
546
+ // refined object has none. The at-least-one check is the handler's.
547
+ const mrPath = (p: { projectPath: string; mergeRequestIid: number }): string =>
548
+ `/projects/${encodeURIComponent(p.projectPath)}/merge_requests/${String(p.mergeRequestIid)}`;
549
+
550
+ registerGitlabWriteTool(
551
+ "gitlab_mr_update",
552
+ {
553
+ mutates: "gitlab.mr.update",
554
+ // An edit overwrites text; its inverse needs the old text, so capture it first.
555
+ recoverable: false,
556
+ capturePreState: async (parsed) => {
557
+ const token = requireProcessEnv("GITLAB_PAT");
558
+ const res = await glFetch(token, mrPath(parsed));
559
+ const mr = res.json as {
560
+ title?: unknown;
561
+ description?: unknown;
562
+ target_branch?: unknown;
563
+ } | null;
564
+ if (!res.ok || mr === null) return { resolved: false, status: res.status };
565
+ return { title: mr.title, description: mr.description, target_branch: mr.target_branch };
566
+ },
567
+ },
568
+ "Edit a merge request's title, description or target branch (at least one). It never closes, reopens or merges it.",
569
+ gitlabMrGetSchema.extend({
570
+ title: z.string().min(1).max(255).optional(),
571
+ description: z.string().max(65_000).optional(),
572
+ targetBranch: branchNameSchema.optional(),
573
+ }),
574
+ mrPath,
575
+ (parsed) => {
576
+ if (
577
+ parsed.title === undefined &&
578
+ parsed.description === undefined &&
579
+ parsed.targetBranch === undefined
580
+ ) {
581
+ throw new Error("gitlab_mr_update needs at least one of title, description, targetBranch");
582
+ }
583
+ return jsonBody("PUT", {
584
+ ...(parsed.title === undefined ? {} : { title: parsed.title }),
585
+ ...(parsed.description === undefined ? {} : { description: parsed.description }),
586
+ ...(parsed.targetBranch === undefined ? {} : { target_branch: parsed.targetBranch }),
587
+ });
588
+ },
589
+ );
590
+
424
591
  const gitlabPipelineListSchema = projectPathArg.extend({
425
592
  ref: z.string().max(500).optional(),
426
593
  status: z.string().max(64).optional(),
@@ -470,9 +470,17 @@ export function registerGoogleDriveTools(
470
470
  fileId: z.string().min(1),
471
471
  });
472
472
 
473
- registerDriveTool(
473
+ // A write, not a read: it was registered as a read before 0.6.0, so outside the gateway it had
474
+ // no consent gate (the gateway already classified it as `google_drive.file.trash`).
475
+ registerDriveWriteTool(
474
476
  "gdrive_file_trash",
475
- "Move a Drive file or folder to trash (recoverable). Requires Gateway HITL file.delete.",
477
+ {
478
+ mutates: "google_drive.file.trash",
479
+ // Drive keeps a trashed item for 30 days and it can be restored from the trash.
480
+ recoverable: true,
481
+ scopeTargetOf: (p) => ({ kind: "file", value: p.fileId }),
482
+ },
483
+ "Move a Drive file or folder to trash (recoverable from the Drive trash for 30 days). Requires Gateway HITL google_drive.file.trash.",
476
484
  gdriveFileTrashArgs,
477
485
  (args, token) => drivePatchJson(token, args.fileId, { trashed: true }),
478
486
  );
@@ -2,7 +2,7 @@
2
2
  "id": "com.nimbus.jira",
3
3
  "displayName": "Jira",
4
4
  "version": "0.1.0",
5
- "description": "Jira REST connector (issues, search, projects). Tenant host (`*.atlassian.net`) comes from jira.base_url; PR 1 static manifest lists the API gateway only. Mutations require Gateway HITL.",
5
+ "description": "Jira REST connector (issues, search, projects, and JSM Operations alerts: get, list, request status, acknowledge, close). Tenant host (`*.atlassian.net`) comes from jira.base_url; PR 1 static manifest lists the API gateway only. Mutations require Gateway HITL.",
6
6
  "author": "Nimbus",
7
7
  "entrypoint": "dist/server.js",
8
8
  "runtime": "bun",
@@ -49,6 +49,152 @@ function plainToAdf(text: string): Record<string, unknown> {
49
49
  };
50
50
  }
51
51
 
52
+ type JiraConfig = { baseUrl: string; email: string; token: string };
53
+ type HttpText = { ok: boolean; status: number; text: string };
54
+
55
+ /** Atlassian's API gateway for JSM Operations. The host is fixed: it is never taken from an argument. */
56
+ const OPS_API_ROOT = "https://api.atlassian.com/jsm/ops/api";
57
+
58
+ /** A cloud id is a UUID. It enters a URL path, so anything else is refused, not encoded. */
59
+ const CLOUD_ID_PATTERN = /^[0-9a-f-]{36}$/i;
60
+
61
+ /** Discovered cloud ids, per site, for the life of the process. */
62
+ const discoveredCloudIds = new Map<string, string>();
63
+
64
+ /** Test seam: forget discovered cloud ids. */
65
+ export function resetJiraCloudIdCacheForTests(): void {
66
+ discoveredCloudIds.clear();
67
+ }
68
+
69
+ function assertCloudId(value: unknown, source: string): string {
70
+ if (typeof value !== "string" || !CLOUD_ID_PATTERN.test(value)) {
71
+ throw new Error(
72
+ `${source} did not give a valid Jira cloud id; set JIRA_CLOUD_ID to the site's cloud id`,
73
+ );
74
+ }
75
+ return value;
76
+ }
77
+
78
+ /** A request is never allowed to follow a redirect: the credential goes only where it was aimed. */
79
+ function refuseRedirect(what: string, res: HttpText): HttpText {
80
+ if (res.status >= 300 && res.status < 400) {
81
+ throw new Error(
82
+ `${what} answered ${String(res.status)} with a redirect; refused (for a write, it may or may not have been applied)`,
83
+ );
84
+ }
85
+ return res;
86
+ }
87
+
88
+ function isAtlassianCloudHost(baseUrl: string): boolean {
89
+ try {
90
+ // https only: over plain http an on-path attacker could answer tenant_info with another tenant's id.
91
+ const u = new URL(baseUrl);
92
+ return u.protocol === "https:" && u.hostname.toLowerCase().endsWith(".atlassian.net");
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * The site's cloud id: `JIRA_CLOUD_ID` when set, else `GET {site}/_edge/tenant_info` once per
100
+ * process per site, only for an `*.atlassian.net` base (Atlassian's documented lookup, sent with the same Basic header as every other Jira call,
101
+ * so the credential goes only to the host it already goes to). Fails closed: no id, no ops call.
102
+ */
103
+ async function resolveCloudId(cfg: JiraConfig): Promise<string> {
104
+ const configured = process.env["JIRA_CLOUD_ID"]?.trim();
105
+ if (configured !== undefined && configured !== "") {
106
+ return assertCloudId(configured, "JIRA_CLOUD_ID");
107
+ }
108
+ const cached = discoveredCloudIds.get(cfg.baseUrl);
109
+ if (cached !== undefined) return cached;
110
+ // tenant_info is an Atlassian Cloud endpoint. A Data Center or proxied site has none, so no
111
+ // request is made to guess: the owner names the cloud id.
112
+ if (!isAtlassianCloudHost(cfg.baseUrl)) {
113
+ throw new Error(
114
+ "JIRA_CLOUD_ID is required: JIRA_BASE_URL is not an *.atlassian.net site, so the cloud id cannot be discovered",
115
+ );
116
+ }
117
+ const res = refuseRedirect(
118
+ "Jira tenant_info",
119
+ await jiraFetch(cfg.baseUrl, cfg.email, cfg.token, "/_edge/tenant_info", {
120
+ redirect: "manual",
121
+ }),
122
+ );
123
+ if (!res.ok) {
124
+ throw new Error(
125
+ `Jira tenant_info ${String(res.status)}: could not discover the cloud id; set JIRA_CLOUD_ID`,
126
+ );
127
+ }
128
+ let parsed: unknown;
129
+ try {
130
+ parsed = JSON.parse(res.text);
131
+ } catch {
132
+ throw new Error("Jira tenant_info was not JSON; set JIRA_CLOUD_ID to the site's cloud id");
133
+ }
134
+ const id = assertCloudId((parsed as { cloudId?: unknown } | null)?.cloudId, "Jira tenant_info");
135
+ discoveredCloudIds.set(cfg.baseUrl, id);
136
+ return id;
137
+ }
138
+
139
+ /** One JSM Operations request: `https://api.atlassian.com/jsm/ops/api/{cloudId}/v1{path}`. */
140
+ async function opsFetch(cfg: JiraConfig, path: string, init?: RequestInit): Promise<HttpText> {
141
+ const cloudId = await resolveCloudId(cfg);
142
+ const res = await fetchAtlassianBasicAuthJsonText(
143
+ `${OPS_API_ROOT}/${cloudId}/v1${path}`,
144
+ cfg.email,
145
+ cfg.token,
146
+ { ...init, redirect: "manual" },
147
+ );
148
+ return refuseRedirect("Jira Operations", res);
149
+ }
150
+
151
+ /** Ids that reach a path: the documented alert id, and request ids, are plain tokens. */
152
+ const opsIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
153
+
154
+ const alertIdSchema = z.object({
155
+ alertId: z
156
+ .string()
157
+ .regex(opsIdPattern, "the alert id (not a tinyId or alias): letters, digits, '_' and '-'"),
158
+ });
159
+
160
+ /** What the audit record needs to understand an alert before it was acknowledged or closed. */
161
+ async function alertPreState(parsed: { alertId: string }): Promise<Record<string, unknown>> {
162
+ const res = await opsFetch(requireJiraConfig(), `/alerts/${encodeURIComponent(parsed.alertId)}`);
163
+ if (!res.ok) {
164
+ throw new Error(`Jira Operations ${String(res.status)}: ${res.text.slice(0, 200)}`);
165
+ }
166
+ const alert = JSON.parse(res.text) as Record<string, unknown>;
167
+ return {
168
+ status: alert["status"],
169
+ acknowledged: alert["acknowledged"],
170
+ message: alert["message"],
171
+ priority: alert["priority"],
172
+ };
173
+ }
174
+
175
+ /** The 202 body of an async alert action, without claiming the action has happened. */
176
+ function acceptedResult(res: HttpText): ReturnType<typeof jsonResult> {
177
+ if (!res.ok) {
178
+ throw new Error(`Jira Operations ${String(res.status)}: ${res.text.slice(0, 400)}`);
179
+ }
180
+ // A 2xx means Atlassian accepted the request; a body that is not JSON must not turn that into
181
+ // an error the owner would read as "nothing happened" and retry.
182
+ let body: { requestId?: unknown; took?: unknown } = {};
183
+ if (res.text !== "") {
184
+ try {
185
+ const parsed: unknown = JSON.parse(res.text);
186
+ if (parsed !== null && typeof parsed === "object") body = parsed;
187
+ } catch {
188
+ body = {};
189
+ }
190
+ }
191
+ return jsonResult({
192
+ status: "accepted",
193
+ requestId: typeof body.requestId === "string" ? body.requestId : null,
194
+ took: body.took,
195
+ });
196
+ }
197
+
52
198
  /** Tool names exposed by this connector — for contract/introspection tests. */
53
199
  export const JIRA_TOOL_NAMES = [
54
200
  "jira_issue_list",
@@ -61,6 +207,11 @@ export const JIRA_TOOL_NAMES = [
61
207
  "jira_board_list",
62
208
  "jira_sprint_list",
63
209
  "jira_epic_list",
210
+ "jira_ops_alert_get",
211
+ "jira_ops_alert_list",
212
+ "jira_ops_alert_request_status",
213
+ "jira_ops_alert_acknowledge",
214
+ "jira_ops_alert_close",
64
215
  ] as const;
65
216
 
66
217
  export function registerJiraTools(
@@ -78,7 +229,7 @@ export function registerJiraTools(
78
229
  const registerWriteTool = createWriteToolRegistrar(server, {
79
230
  connector: "jira",
80
231
  scopeEnv: "NIMBUS_MCP_JIRA_WRITE_SCOPE",
81
- scopeKinds: ["project", "issue"],
232
+ scopeKinds: ["project", "issue", "alert"],
82
233
  });
83
234
 
84
235
  const jiraIssueListSchema = z.object({
@@ -395,4 +546,106 @@ export function registerJiraTools(
395
546
  });
396
547
  },
397
548
  );
549
+
550
+ reg(
551
+ "jira_ops_alert_get",
552
+ "Get a Jira Service Management Operations alert by its id (GET /alerts/{id} on the JSM Operations API): status, acknowledged, message, priority, responders. Takes the alert id, not a tinyId or alias.",
553
+ alertIdSchema,
554
+ async (parsed) => {
555
+ const res = await opsFetch(
556
+ requireJiraConfig(),
557
+ `/alerts/${encodeURIComponent(parsed.alertId)}`,
558
+ );
559
+ return mcpJsonResultFromTextIfOk("Jira Operations", res, {
560
+ jsonParseErrorMessage: "Jira Operations: invalid JSON from alert get",
561
+ });
562
+ },
563
+ );
564
+
565
+ reg(
566
+ "jira_ops_alert_list",
567
+ "List Jira Service Management Operations alerts (GET /alerts). `query` is the Operations search query; `size` 1-100 (default 25); offset plus size must stay under 20000.",
568
+ z.object({
569
+ query: z.string().min(1).max(1000).optional(),
570
+ size: z.number().int().min(1).max(100).optional(),
571
+ offset: z.number().int().min(0).optional(),
572
+ sort: z.enum(["createdAt", "updatedAt"]).optional(),
573
+ order: z.enum(["asc", "desc"]).optional(),
574
+ from: z.string().min(1).max(64).optional(),
575
+ to: z.string().min(1).max(64).optional(),
576
+ }),
577
+ async (parsed) => {
578
+ const size = parsed.size ?? 25;
579
+ const offset = parsed.offset ?? 0;
580
+ if (offset + size >= 20_000) {
581
+ throw new Error("offset plus size must be lower than 20000");
582
+ }
583
+ const search = new URLSearchParams({ size: String(size), offset: String(offset) });
584
+ for (const key of ["query", "sort", "order", "from", "to"] as const) {
585
+ const value = parsed[key];
586
+ if (value !== undefined) search.set(key, value);
587
+ }
588
+ const res = await opsFetch(requireJiraConfig(), `/alerts?${search.toString()}`);
589
+ return mcpJsonResultFromTextIfOk("Jira Operations", res, {
590
+ jsonParseErrorMessage: "Jira Operations: invalid JSON from alert list",
591
+ });
592
+ },
593
+ );
594
+
595
+ reg(
596
+ "jira_ops_alert_request_status",
597
+ "Check an asynchronous alert action (GET /alerts/requests/{id}) with the requestId that jira_ops_alert_acknowledge or jira_ops_alert_close returned: whether it was processed and succeeded.",
598
+ z.object({ requestId: z.string().regex(opsIdPattern, "letters, digits, '_' and '-' only") }),
599
+ async (parsed) => {
600
+ const res = await opsFetch(
601
+ requireJiraConfig(),
602
+ `/alerts/requests/${encodeURIComponent(parsed.requestId)}`,
603
+ );
604
+ return mcpJsonResultFromTextIfOk("Jira Operations", res, {
605
+ jsonParseErrorMessage: "Jira Operations: invalid JSON from request status",
606
+ });
607
+ },
608
+ );
609
+
610
+ registerWriteTool(
611
+ "jira_ops_alert_acknowledge",
612
+ {
613
+ mutates: "jira.ops.alert.acknowledge",
614
+ // No unacknowledge tool is built, so the alert's state before is captured instead.
615
+ recoverable: false,
616
+ capturePreState: alertPreState,
617
+ scopeTargetOf: (p) => ({ kind: "alert", value: p.alertId }),
618
+ },
619
+ "Acknowledge a Jira Service Management Operations alert (POST /alerts/{id}/acknowledge; the API takes no body). The action is asynchronous: this returns {status: accepted, requestId}, not completion; check it with jira_ops_alert_request_status. Takes the alert id, not a tinyId or alias.",
620
+ alertIdSchema,
621
+ async (parsed) => {
622
+ const res = await opsFetch(
623
+ requireJiraConfig(),
624
+ `/alerts/${encodeURIComponent(parsed.alertId)}/acknowledge`,
625
+ { method: "POST" },
626
+ );
627
+ return acceptedResult(res);
628
+ },
629
+ );
630
+
631
+ registerWriteTool(
632
+ "jira_ops_alert_close",
633
+ {
634
+ mutates: "jira.ops.alert.close",
635
+ // The Operations API has no reopen, so the alert's state before is captured.
636
+ recoverable: false,
637
+ capturePreState: alertPreState,
638
+ scopeTargetOf: (p) => ({ kind: "alert", value: p.alertId }),
639
+ },
640
+ "Close a Jira Service Management Operations alert (POST /alerts/{id}/close; the API takes no body). It cannot be reopened through this API. The action is asynchronous: this returns {status: accepted, requestId}, not completion. To resolve an incident use jira_issue_transition; incidents are Jira issues.",
641
+ alertIdSchema,
642
+ async (parsed) => {
643
+ const res = await opsFetch(
644
+ requireJiraConfig(),
645
+ `/alerts/${encodeURIComponent(parsed.alertId)}/close`,
646
+ { method: "POST" },
647
+ );
648
+ return acceptedResult(res);
649
+ },
650
+ );
398
651
  }
@@ -2,14 +2,14 @@
2
2
  "id": "com.nimbus.launchdarkly",
3
3
  "displayName": "LaunchDarkly",
4
4
  "version": "0.1.0",
5
- "description": "LaunchDarkly feature-flag connector (read-only). Surfaces flag key, name, kind, tags, per-environment on/off state, maintainer, and timestamps into the local index as `launchdarkly:feature_flag` items — useful for incident correlation (\"was this flag enabled when the alert fired?\"). API-token auth. Regional / federal instances override `launchdarkly.base_url`; the SaaS host (app.launchdarkly.com) is the only one in the static sandbox manifest today (Task 14 runtime-merge follow-up). The `launchdarkly.flag.toggle` write tool is a deferred Phase 8 follow-up.",
5
+ "description": "LaunchDarkly feature-flag connector. Surfaces flag key, name, kind, tags, per-environment on/off state, maintainer, and timestamps into the local index as `launchdarkly:feature_flag` items — useful for incident correlation (\"was this flag enabled when the alert fired?\"). API-token auth. Regional / federal instances override `launchdarkly.base_url`; the SaaS host (app.launchdarkly.com) is the only one in the static sandbox manifest today (Task 14 runtime-merge follow-up). Write tools (each needs owner approval): `launchdarkly.flag.toggle` and `launchdarkly.flag.rollout.set`, one environment per call. The API token needs a role that can update flags.",
6
6
  "author": "Nimbus",
7
7
  "entrypoint": "dist/server.js",
8
8
  "runtime": "bun",
9
9
  "permissions": {
10
10
  "network": ["app.launchdarkly.com"]
11
11
  },
12
- "hitlRequired": [],
12
+ "hitlRequired": ["write"],
13
13
  "syncInterval": 600,
14
14
  "minNimbusVersion": "0.2.0"
15
15
  }