@nimbus-dev/connectors 0.5.0 → 0.7.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/connectors/flagsmith/nimbus.extension.json +2 -2
- package/connectors/flagsmith/src/server.ts +12 -4
- package/connectors/flagsmith/src/tools.ts +147 -7
- package/connectors/github/src/tools.ts +134 -4
- package/connectors/github-actions/src/tools.ts +3 -2
- package/connectors/gitlab/src/tools.ts +186 -19
- package/connectors/google-drive/src/tools.ts +10 -2
- package/connectors/jira/nimbus.extension.json +1 -1
- package/connectors/jira/src/tools.ts +254 -1
- package/connectors/launchdarkly/nimbus.extension.json +2 -2
- package/connectors/launchdarkly/src/flag-state.ts +178 -0
- package/connectors/launchdarkly/src/server.ts +12 -4
- package/connectors/launchdarkly/src/tools.ts +184 -10
- package/connectors/pagerduty/src/tools.ts +122 -27
- package/connectors/slack/src/tools.ts +56 -5
- package/connectors/teams/src/bot-send.ts +10 -2
- package/connectors/teams/src/tools.ts +18 -1
- package/package.json +1 -1
- package/shared/env-json-api.ts +57 -15
- package/shared/github-rest.ts +128 -5
- package/shared/guarded-fetch.ts +135 -0
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
-
.
|
|
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
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|