@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.
- 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/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
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the LaunchDarkly flag tools: the one-environment read projection and the
|
|
3
|
+
* rollout-weight arithmetic. Kept apart from the tool registration so both are testable alone.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** LaunchDarkly rollout weights are thousandths of a percent: 0..100000, and a flag sums to 100000. */
|
|
7
|
+
export const FULL_ROLLOUT = 100_000;
|
|
8
|
+
|
|
9
|
+
export interface RolloutWeightInput {
|
|
10
|
+
readonly variationId: string;
|
|
11
|
+
readonly percent: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `weights` as the `rolloutWeights` map the semantic patch takes (variation id -> thousandths of a
|
|
16
|
+
* percent). Fails closed: duplicate ids, a percent with more than three decimals, or a total that is
|
|
17
|
+
* not EXACTLY 100% all throw before any request is made. (The API only says the sum "should" be 100%;
|
|
18
|
+
* what it does on a mismatch is not stated, so this connector does not find out on a production flag.)
|
|
19
|
+
*/
|
|
20
|
+
export function rolloutWeightsOf(weights: readonly RolloutWeightInput[]): Record<string, number> {
|
|
21
|
+
const out: Record<string, number> = {};
|
|
22
|
+
let total = 0;
|
|
23
|
+
for (const w of weights) {
|
|
24
|
+
if (Object.hasOwn(out, w.variationId)) {
|
|
25
|
+
throw new Error(`variationId ${w.variationId} appears more than once`);
|
|
26
|
+
}
|
|
27
|
+
const thousandths = Math.round(w.percent * 1000);
|
|
28
|
+
if (Math.abs(w.percent * 1000 - thousandths) > 1e-6) {
|
|
29
|
+
throw new Error(`percent ${String(w.percent)} has more than 3 decimal places`);
|
|
30
|
+
}
|
|
31
|
+
out[w.variationId] = thousandths;
|
|
32
|
+
total += thousandths;
|
|
33
|
+
}
|
|
34
|
+
if (total !== FULL_ROLLOUT) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`weights must sum to exactly 100 percent, got ${String(total / 1000)}; nothing was changed`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The `on` a flag-change reply shows for `environmentKey`, when it is a boolean. */
|
|
43
|
+
export function echoedOn(response: unknown, environmentKey: string): boolean | undefined {
|
|
44
|
+
const envs =
|
|
45
|
+
isRecord(response) && isRecord(response["environments"]) ? response["environments"] : {};
|
|
46
|
+
const env = envs[environmentKey];
|
|
47
|
+
return isRecord(env) && typeof env["on"] === "boolean" ? env["on"] : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `environmentKey`'s fallthrough rollout object in a flag-change reply, when there is one. */
|
|
51
|
+
function fallthroughRolloutOf(
|
|
52
|
+
response: Record<string, unknown>,
|
|
53
|
+
environmentKey: string,
|
|
54
|
+
): Record<string, unknown> | undefined {
|
|
55
|
+
const envs = isRecord(response["environments"]) ? response["environments"] : {};
|
|
56
|
+
const env = envs[environmentKey];
|
|
57
|
+
const fallthrough = isRecord(env) && isRecord(env["fallthrough"]) ? env["fallthrough"] : {};
|
|
58
|
+
const rollout = fallthrough["rollout"];
|
|
59
|
+
return isRecord(rollout) ? rollout : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The echoed rollout weights by variation id, or `undefined` when any entry cannot be resolved to a
|
|
64
|
+
* variation id and a numeric weight.
|
|
65
|
+
*/
|
|
66
|
+
function echoedWeights(
|
|
67
|
+
rollout: Record<string, unknown>,
|
|
68
|
+
variations: readonly unknown[],
|
|
69
|
+
): Record<string, number> | undefined {
|
|
70
|
+
const echoed: Record<string, number> = {};
|
|
71
|
+
const entries = Array.isArray(rollout["variations"]) ? rollout["variations"] : [];
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
const row = isRecord(entry) ? entry : {};
|
|
74
|
+
const index = row["variation"];
|
|
75
|
+
const weight = row["weight"];
|
|
76
|
+
const variation = typeof index === "number" ? variations[index] : undefined;
|
|
77
|
+
const id = isRecord(variation) ? variation["_id"] : undefined;
|
|
78
|
+
if (typeof id !== "string" || typeof weight !== "number") return undefined;
|
|
79
|
+
echoed[id] = weight;
|
|
80
|
+
}
|
|
81
|
+
return echoed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* True only when the reply carries `environmentKey`'s fallthrough rollout and its weights (by
|
|
86
|
+
* variation id, a missing id counting as 0) equal `requested`. A reply without them is not a
|
|
87
|
+
* confirmation.
|
|
88
|
+
*/
|
|
89
|
+
export function rolloutEchoed(
|
|
90
|
+
response: unknown,
|
|
91
|
+
environmentKey: string,
|
|
92
|
+
requested: Readonly<Record<string, number>>,
|
|
93
|
+
): boolean {
|
|
94
|
+
if (!isRecord(response)) return false;
|
|
95
|
+
const rollout = fallthroughRolloutOf(response, environmentKey);
|
|
96
|
+
if (rollout === undefined || !Array.isArray(rollout["variations"])) return false;
|
|
97
|
+
const variations = Array.isArray(response["variations"]) ? response["variations"] : [];
|
|
98
|
+
const echoed = echoedWeights(rollout, variations);
|
|
99
|
+
if (echoed === undefined) return false;
|
|
100
|
+
const ids = new Set([...Object.keys(requested), ...Object.keys(echoed)]);
|
|
101
|
+
for (const id of ids) {
|
|
102
|
+
if ((requested[id] ?? 0) !== (echoed[id] ?? 0)) return false;
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
108
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function numberOrUndefined(v: unknown): number | undefined {
|
|
112
|
+
return typeof v === "number" ? v : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The state of one flag in one environment, from `GET /flags/{p}/{f}?env={e}`.
|
|
117
|
+
*
|
|
118
|
+
* Variation references (`fallthrough.variation`, `offVariation`, `rollout.variations[].variation`)
|
|
119
|
+
* are read as INDICES into `variations[]`, which is how the schema describes `fallthrough.variation`.
|
|
120
|
+
* The same schema calls `offVariation` an "ID" in one place; the raw number is returned beside the
|
|
121
|
+
* resolved id so a caller can see which reading applied. Pinned by a fixture; live-check item.
|
|
122
|
+
*/
|
|
123
|
+
export function projectFlagState(
|
|
124
|
+
raw: unknown,
|
|
125
|
+
ids: { projectKey: string; flagKey: string; environmentKey: string },
|
|
126
|
+
): Record<string, unknown> {
|
|
127
|
+
const flag = isRecord(raw) ? raw : {};
|
|
128
|
+
const envs = isRecord(flag["environments"]) ? flag["environments"] : {};
|
|
129
|
+
const env = envs[ids.environmentKey];
|
|
130
|
+
if (!isRecord(env)) {
|
|
131
|
+
throw new Error(`LaunchDarkly environment not found: ${ids.environmentKey}`);
|
|
132
|
+
}
|
|
133
|
+
const rawVariations = Array.isArray(flag["variations"]) ? flag["variations"] : [];
|
|
134
|
+
const variations = rawVariations.map((v, index) => {
|
|
135
|
+
const row = isRecord(v) ? v : {};
|
|
136
|
+
return { index, id: row["_id"], name: row["name"], value: row["value"] };
|
|
137
|
+
});
|
|
138
|
+
const idAt = (index: unknown): unknown =>
|
|
139
|
+
typeof index === "number" ? variations[index]?.id : undefined;
|
|
140
|
+
|
|
141
|
+
const fallthroughRaw = isRecord(env["fallthrough"]) ? env["fallthrough"] : {};
|
|
142
|
+
const fallthrough: Record<string, unknown> = {};
|
|
143
|
+
if (typeof fallthroughRaw["variation"] === "number") {
|
|
144
|
+
fallthrough["variation"] = fallthroughRaw["variation"];
|
|
145
|
+
fallthrough["variationId"] = idAt(fallthroughRaw["variation"]);
|
|
146
|
+
}
|
|
147
|
+
const rollout = fallthroughRaw["rollout"];
|
|
148
|
+
if (isRecord(rollout) && Array.isArray(rollout["variations"])) {
|
|
149
|
+
fallthrough["rollout"] = rollout["variations"].map((entry) => {
|
|
150
|
+
const row = isRecord(entry) ? entry : {};
|
|
151
|
+
const weight = numberOrUndefined(row["weight"]);
|
|
152
|
+
return {
|
|
153
|
+
variation: row["variation"],
|
|
154
|
+
variationId: idAt(row["variation"]),
|
|
155
|
+
weightPercent: weight === undefined ? undefined : weight / 1000,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
if (isRecord(rollout)) {
|
|
160
|
+
if (typeof rollout["bucketBy"] === "string")
|
|
161
|
+
fallthrough["rolloutBucketBy"] = rollout["bucketBy"];
|
|
162
|
+
if (typeof rollout["contextKind"] === "string") {
|
|
163
|
+
fallthrough["rolloutContextKind"] = rollout["contextKind"];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
projectKey: ids.projectKey,
|
|
168
|
+
flagKey: ids.flagKey,
|
|
169
|
+
environmentKey: ids.environmentKey,
|
|
170
|
+
on: env["on"],
|
|
171
|
+
offVariation: env["offVariation"],
|
|
172
|
+
offVariationId: idAt(env["offVariation"]),
|
|
173
|
+
fallthrough,
|
|
174
|
+
variations,
|
|
175
|
+
version: numberOrUndefined(env["version"]) ?? numberOrUndefined(flag["_version"]),
|
|
176
|
+
archived: flag["archived"],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { CONSENT_SERVER_CAPABILITIES } from "../../../shared/consent-kit.ts";
|
|
2
4
|
import { registerLaunchdarklyTools } from "./tools.ts";
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
6
|
+
const server = new McpServer(
|
|
7
|
+
{ name: "nimbus-launchdarkly", version: "0.1.0" },
|
|
8
|
+
{ capabilities: CONSENT_SERVER_CAPABILITIES },
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
registerLaunchdarklyTools(server);
|
|
12
|
+
|
|
13
|
+
const transport = new StdioServerTransport();
|
|
14
|
+
await server.connect(transport);
|
|
@@ -1,32 +1,133 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { type ConsentServer, createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
|
|
3
|
+
import {
|
|
4
|
+
createJsonGetter,
|
|
5
|
+
createJsonSender,
|
|
6
|
+
envAuthHeaders,
|
|
7
|
+
type JsonApiConfig,
|
|
8
|
+
optionalEnv,
|
|
9
|
+
} from "../../../shared/env-json-api.ts";
|
|
3
10
|
import { matchesResult } from "../../../shared/mcp-search-tool.ts";
|
|
4
|
-
import {
|
|
5
|
-
|
|
11
|
+
import {
|
|
12
|
+
createRegisterSimpleTool,
|
|
13
|
+
createZodToolRegistrar,
|
|
14
|
+
mcpJsonResult as jsonResult,
|
|
15
|
+
} from "../../../shared/mcp-tool-kit.ts";
|
|
16
|
+
import { echoedOn, projectFlagState, rolloutEchoed, rolloutWeightsOf } from "./flag-state.ts";
|
|
6
17
|
import { filterLaunchDarklyFlags } from "./search-filter.ts";
|
|
7
18
|
|
|
8
19
|
const DEFAULT_BASE = "https://app.launchdarkly.com";
|
|
9
20
|
|
|
10
|
-
const
|
|
21
|
+
const api: JsonApiConfig = {
|
|
11
22
|
// `optionalEnv`, not `optionalBaseUrl`: a trailing slash on the override is left as given.
|
|
12
23
|
base: () => `${optionalEnv("LAUNCHDARKLY_BASE_URL", DEFAULT_BASE)}/api/v2`,
|
|
13
24
|
label: "LaunchDarkly",
|
|
14
25
|
headers: envAuthHeaders({ env: "LAUNCHDARKLY_TOKEN", scheme: "" }),
|
|
15
|
-
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const ldGet = createJsonGetter(api);
|
|
29
|
+
const ldSend = createJsonSender(api);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A semantic patch needs `domain-model=launchdarkly.semanticpatch` appended to Content-Type, else
|
|
33
|
+
* LaunchDarkly treats the body as a JSON patch and answers 400
|
|
34
|
+
* (https://launchdarkly.com/docs/guides/api/rest-api, "append `domain-model=launchdarkly.semanticpatch`
|
|
35
|
+
* to your `Content-Type` header").
|
|
36
|
+
*/
|
|
37
|
+
const SEMANTIC_PATCH_HEADERS = {
|
|
38
|
+
"Content-Type": "application/json; domain-model=launchdarkly.semanticpatch",
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
/** Written into the flag's change history, so the audit trail says who made the change. */
|
|
42
|
+
const CHANGE_COMMENT = "Changed by Nimbus after owner approval";
|
|
16
43
|
|
|
17
44
|
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
18
45
|
export const LAUNCHDARKLY_TOOL_NAMES = [
|
|
19
46
|
"launchdarkly_list",
|
|
20
47
|
"launchdarkly_get",
|
|
21
48
|
"launchdarkly_search",
|
|
49
|
+
"launchdarkly_flag_state_get",
|
|
50
|
+
"launchdarkly_flag_toggle",
|
|
51
|
+
"launchdarkly_flag_rollout_set",
|
|
22
52
|
] as const;
|
|
23
53
|
|
|
24
|
-
|
|
54
|
+
/**
|
|
55
|
+
* LaunchDarkly project, environment and flag keys: letters, digits, `.`, `_`, `-`, but never `.` or
|
|
56
|
+
* `..` alone: `encodeURIComponent("..")` is `..`, and a URL parser collapses it out of the path.
|
|
57
|
+
*/
|
|
58
|
+
const keyArg = z
|
|
59
|
+
.string()
|
|
60
|
+
.regex(
|
|
61
|
+
/^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/,
|
|
62
|
+
"letters, digits, '.', '_' and '-' only; not '.' or '..'",
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/** The read tools keep accepting any key (it is percent-encoded), but never a dot segment. */
|
|
66
|
+
const readKeyArg = z
|
|
67
|
+
.string()
|
|
68
|
+
.min(1)
|
|
69
|
+
.regex(/^(?!\.{1,2}$)/, "not '.' or '..'");
|
|
70
|
+
|
|
71
|
+
const flagArgs = z.object({
|
|
72
|
+
projectKey: keyArg,
|
|
73
|
+
environmentKey: keyArg,
|
|
74
|
+
flagKey: keyArg,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
type FlagArgs = z.infer<typeof flagArgs>;
|
|
78
|
+
|
|
79
|
+
function flagPath(p: { projectKey: string; flagKey: string }): string {
|
|
80
|
+
return `/flags/${encodeURIComponent(p.projectKey)}/${encodeURIComponent(p.flagKey)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One environment's slice of a flag, so the read stays small however many environments exist. */
|
|
84
|
+
async function readFlagState(p: FlagArgs): Promise<Record<string, unknown>> {
|
|
85
|
+
const search = new URLSearchParams({ env: p.environmentKey });
|
|
86
|
+
const raw = await ldGet(`${flagPath(p)}?${search.toString()}`);
|
|
87
|
+
return projectFlagState(raw, p);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The result of a flag change: the facts, not LaunchDarkly's whole flag document. `confirmed` is
|
|
92
|
+
* true only when the reply SHOWS the requested state; false means the server accepted the write
|
|
93
|
+
* but its reply does not show the new state.
|
|
94
|
+
*/
|
|
95
|
+
function changed(
|
|
96
|
+
p: FlagArgs,
|
|
97
|
+
response: unknown,
|
|
98
|
+
confirmed: (response: unknown) => boolean,
|
|
99
|
+
): Record<string, unknown> {
|
|
100
|
+
const flag = (response ?? {}) as { environments?: Record<string, { on?: unknown }> };
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
confirmed: confirmed(response),
|
|
104
|
+
projectKey: p.projectKey,
|
|
105
|
+
environmentKey: p.environmentKey,
|
|
106
|
+
flagKey: p.flagKey,
|
|
107
|
+
on: flag.environments?.[p.environmentKey]?.on,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Scope: `flag:<projectKey>/<environmentKey>/<flagKey>`. */
|
|
112
|
+
function flagScope(p: FlagArgs): { kind: string; value: string } {
|
|
113
|
+
return { kind: "flag", value: `${p.projectKey}/${p.environmentKey}/${p.flagKey}` };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function registerLaunchdarklyTools(
|
|
117
|
+
server: ConsentServer & { tool: (...args: never) => unknown },
|
|
118
|
+
): void {
|
|
119
|
+
const reg = createZodToolRegistrar(createRegisterSimpleTool(server));
|
|
120
|
+
const registerWriteTool = createWriteToolRegistrar(server, {
|
|
121
|
+
connector: "launchdarkly",
|
|
122
|
+
scopeEnv: "NIMBUS_MCP_LAUNCHDARKLY_WRITE_SCOPE",
|
|
123
|
+
scopeKinds: ["flag"],
|
|
124
|
+
});
|
|
125
|
+
|
|
25
126
|
reg(
|
|
26
127
|
"launchdarkly_list",
|
|
27
128
|
"List LaunchDarkly projects, or feature flags for a project. Without `projectKey`, returns the account's projects (`/projects`). With `projectKey`, returns that project's flags (`/flags/{projectKey}`), capped at `limit` (default 100).",
|
|
28
129
|
z.object({
|
|
29
|
-
projectKey:
|
|
130
|
+
projectKey: readKeyArg.optional(),
|
|
30
131
|
limit: z.number().int().min(1).max(500).optional(),
|
|
31
132
|
}),
|
|
32
133
|
async (p) => {
|
|
@@ -44,8 +145,8 @@ export function registerLaunchdarklyTools(reg: ZodToolRegistrar): void {
|
|
|
44
145
|
"launchdarkly_get",
|
|
45
146
|
"Fetch one LaunchDarkly feature flag by project key + flag key. Throws when no match is found.",
|
|
46
147
|
z.object({
|
|
47
|
-
projectKey:
|
|
48
|
-
flagKey:
|
|
148
|
+
projectKey: readKeyArg,
|
|
149
|
+
flagKey: readKeyArg,
|
|
49
150
|
}),
|
|
50
151
|
async (p) => {
|
|
51
152
|
return jsonResult(
|
|
@@ -58,7 +159,7 @@ export function registerLaunchdarklyTools(reg: ZodToolRegistrar): void {
|
|
|
58
159
|
"launchdarkly_search",
|
|
59
160
|
"Substring search across a project's feature flags. Matches the query against flag key, name, description, and tags (case-insensitive). Returns a `{ matches: [...] }` envelope.",
|
|
60
161
|
z.object({
|
|
61
|
-
projectKey:
|
|
162
|
+
projectKey: readKeyArg,
|
|
62
163
|
query: z.string().min(1),
|
|
63
164
|
limit: z.number().int().min(1).max(200).optional(),
|
|
64
165
|
}),
|
|
@@ -69,4 +170,77 @@ export function registerLaunchdarklyTools(reg: ZodToolRegistrar): void {
|
|
|
69
170
|
return matchesResult(flags, filterLaunchDarklyFlags, p);
|
|
70
171
|
},
|
|
71
172
|
);
|
|
173
|
+
|
|
174
|
+
reg(
|
|
175
|
+
"launchdarkly_flag_state_get",
|
|
176
|
+
"Read one feature flag's state in ONE environment (`GET /flags/{projectKey}/{flagKey}?env={environmentKey}`): on/off, off variation, fallthrough (a single variation or a percentage rollout, with variation ids and weights in percent), the variation list and the environment's version. Smaller than `launchdarkly_get`, which returns every environment. Variation references are read as indices into `variations` (LaunchDarkly's schema is ambiguous for `offVariation`); the raw number is returned beside the resolved id.",
|
|
177
|
+
flagArgs,
|
|
178
|
+
async (p) => jsonResult(await readFlagState(p)),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
registerWriteTool(
|
|
182
|
+
"launchdarkly_flag_toggle",
|
|
183
|
+
{
|
|
184
|
+
mutates: "launchdarkly.flag.toggle",
|
|
185
|
+
recoverable: true,
|
|
186
|
+
scopeTargetOf: flagScope,
|
|
187
|
+
},
|
|
188
|
+
"Turn a feature flag on or off in ONE environment (`PATCH /flags/{projectKey}/{flagKey}`, semantic patch turnFlagOn / turnFlagOff). Reversible by toggling back. An environment that requires approvals answers 405 and nothing changes.",
|
|
189
|
+
flagArgs.extend({ on: z.boolean() }),
|
|
190
|
+
async (p) => {
|
|
191
|
+
const response = await ldSend(
|
|
192
|
+
"PATCH",
|
|
193
|
+
flagPath(p),
|
|
194
|
+
{
|
|
195
|
+
environmentKey: p.environmentKey,
|
|
196
|
+
comment: CHANGE_COMMENT,
|
|
197
|
+
instructions: [{ kind: p.on ? "turnFlagOn" : "turnFlagOff" }],
|
|
198
|
+
},
|
|
199
|
+
SEMANTIC_PATCH_HEADERS,
|
|
200
|
+
);
|
|
201
|
+
return jsonResult(changed(p, response, (r) => echoedOn(r, p.environmentKey) === p.on));
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
registerWriteTool(
|
|
206
|
+
"launchdarkly_flag_rollout_set",
|
|
207
|
+
{
|
|
208
|
+
mutates: "launchdarkly.flag.rollout.set",
|
|
209
|
+
// The previous fallthrough (variation or weights, bucketBy, context kind) is recorded in the
|
|
210
|
+
// audit pre-state. This tool does not restore it, so the write is not marked recoverable.
|
|
211
|
+
recoverable: false,
|
|
212
|
+
capturePreState: readFlagState,
|
|
213
|
+
scopeTargetOf: flagScope,
|
|
214
|
+
},
|
|
215
|
+
"Set a feature flag's fallthrough to a percentage rollout in ONE environment (`PATCH /flags/{projectKey}/{flagKey}`, semantic patch updateFallthroughVariationOrRollout). `weights` maps variation ids (from `launchdarkly_flag_state_get`) to percent; they must sum to exactly 100 with at most 3 decimals, else nothing is sent. The previous fallthrough is recorded in the audit pre-state; this tool does not restore it, and a rollout's bucketBy / context kind are not set by it.",
|
|
216
|
+
flagArgs.extend({
|
|
217
|
+
weights: z
|
|
218
|
+
.array(
|
|
219
|
+
z.object({
|
|
220
|
+
variationId: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/),
|
|
221
|
+
percent: z.number().min(0).max(100),
|
|
222
|
+
}),
|
|
223
|
+
)
|
|
224
|
+
.min(1)
|
|
225
|
+
.max(20),
|
|
226
|
+
}),
|
|
227
|
+
async (p) => {
|
|
228
|
+
// Validated here, not with `.refine` on the schema: a refined schema loses `.shape`, which
|
|
229
|
+
// the registrar passes to the MCP SDK as the tool's input schema.
|
|
230
|
+
const rolloutWeights = rolloutWeightsOf(p.weights);
|
|
231
|
+
const response = await ldSend(
|
|
232
|
+
"PATCH",
|
|
233
|
+
flagPath(p),
|
|
234
|
+
{
|
|
235
|
+
environmentKey: p.environmentKey,
|
|
236
|
+
comment: CHANGE_COMMENT,
|
|
237
|
+
instructions: [{ kind: "updateFallthroughVariationOrRollout", rolloutWeights }],
|
|
238
|
+
},
|
|
239
|
+
SEMANTIC_PATCH_HEADERS,
|
|
240
|
+
);
|
|
241
|
+
return jsonResult(
|
|
242
|
+
changed(p, response, (r) => rolloutEchoed(r, p.environmentKey, rolloutWeights)),
|
|
243
|
+
);
|
|
244
|
+
},
|
|
245
|
+
);
|
|
72
246
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { ConsentServer } from "../../../shared/consent-kit.ts";
|
|
3
3
|
import { createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
|
|
4
|
+
import { guardedFetch } from "../../../shared/guarded-fetch.ts";
|
|
4
5
|
import {
|
|
5
6
|
createRegisterSimpleTool,
|
|
6
7
|
createZodToolRegistrar,
|
|
@@ -18,7 +19,8 @@ const API = "https://api.pagerduty.com";
|
|
|
18
19
|
async function pdFetch(token: string, path: string, init?: RequestInit): Promise<RestFetchResult> {
|
|
19
20
|
const rel = path.startsWith("/") ? path : `/${path}`;
|
|
20
21
|
const url = path.startsWith("http") ? path : `${API}${rel}`;
|
|
21
|
-
|
|
22
|
+
// guardedFetch: a write answered with a redirect is refused, and a GET follows only same-origin.
|
|
23
|
+
const res = await guardedFetch("PagerDuty", url, {
|
|
22
24
|
...init,
|
|
23
25
|
headers: {
|
|
24
26
|
Accept: "application/vnd.pagerduty+json;version=2",
|
|
@@ -30,11 +32,59 @@ async function pdFetch(token: string, path: string, init?: RequestInit): Promise
|
|
|
30
32
|
return toRestFetchResult(res);
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
/**
|
|
36
|
+
* A linear-time email shape check (no regex, so no backtracking): at most 254 characters, no
|
|
37
|
+
* whitespace or control characters, exactly one `@`, a non-empty local part, and a domain that
|
|
38
|
+
* contains a `.` that is neither its first nor its last character.
|
|
39
|
+
*/
|
|
40
|
+
export function isEmailShape(email: string): boolean {
|
|
41
|
+
if (email.length === 0 || email.length > 254) return false;
|
|
42
|
+
for (let i = 0; i < email.length; i++) {
|
|
43
|
+
const c = email.charCodeAt(i);
|
|
44
|
+
if (c <= 0x20 || (c >= 0x7f && c <= 0xa0) || c === 0x2028 || c === 0x2029 || c === 0xfeff) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const at = email.indexOf("@");
|
|
49
|
+
if (at < 1 || at !== email.lastIndexOf("@")) return false;
|
|
50
|
+
const domain = email.slice(at + 1);
|
|
51
|
+
// A "." strictly inside the domain: neither its first nor its last character.
|
|
52
|
+
return domain.length >= 3 && domain.slice(1, -1).includes(".");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The `From` header PagerDuty requires on an incident update: "The email address of a valid user
|
|
57
|
+
* associated with the account making the request". Writes only. Throws before any request.
|
|
58
|
+
* The gateway injects it from the Vault key `pagerduty.from_email`.
|
|
59
|
+
*/
|
|
60
|
+
function requirePdFromEmail(): string {
|
|
61
|
+
const email = requireProcessEnv("PAGERDUTY_FROM_EMAIL");
|
|
62
|
+
if (!isEmailShape(email)) {
|
|
63
|
+
throw new Error("PAGERDUTY_FROM_EMAIL is not an email address");
|
|
64
|
+
}
|
|
65
|
+
return email;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Incident ids are short alphanumerics; anything else never reaches a URL. */
|
|
69
|
+
const incidentIdSchema = z.string().regex(/^[A-Za-z0-9]{1,64}$/);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `PUT /incidents/{id}` with `{incident:{type:"incident_reference", status}}` and a required
|
|
73
|
+
* `From` header: the only update endpoint
|
|
74
|
+
* https://docs.pagerduty.com/developer/api/reference/rest/incidents/update-incident documents
|
|
75
|
+
* (retrieved 2026-11-06). There is no `/acknowledge` or `/resolve` sub-endpoint.
|
|
76
|
+
*/
|
|
77
|
+
async function pdIncidentPut(
|
|
78
|
+
actionLabel: string,
|
|
79
|
+
change: { readonly status: "acknowledged" | "resolved" } | { readonly escalation_level: number },
|
|
80
|
+
incidentId: string,
|
|
81
|
+
) {
|
|
82
|
+
const from = requirePdFromEmail();
|
|
34
83
|
const token = requireProcessEnv("PAGERDUTY_API_TOKEN");
|
|
35
|
-
const res = await pdFetch(token, `/incidents/${encodeURIComponent(incidentId)}
|
|
84
|
+
const res = await pdFetch(token, `/incidents/${encodeURIComponent(incidentId)}`, {
|
|
36
85
|
method: "PUT",
|
|
37
|
-
|
|
86
|
+
headers: { From: from },
|
|
87
|
+
body: JSON.stringify({ incident: { type: "incident_reference", ...change } }),
|
|
38
88
|
});
|
|
39
89
|
if (!res.ok) {
|
|
40
90
|
throw new Error(`PagerDuty ${actionLabel} ${String(res.status)}: ${res.text.slice(0, 400)}`);
|
|
@@ -42,6 +92,24 @@ async function pdIncidentPutMutation(actionLabel: string, pathSuffix: string, in
|
|
|
42
92
|
return jsonResult(res.json ?? { ok: true });
|
|
43
93
|
}
|
|
44
94
|
|
|
95
|
+
/** The incident as it stands: status, assignments, escalation level. Audited before a write. */
|
|
96
|
+
async function readIncidentPreState(incidentId: string): Promise<Record<string, unknown>> {
|
|
97
|
+
requirePdFromEmail();
|
|
98
|
+
const token = requireProcessEnv("PAGERDUTY_API_TOKEN");
|
|
99
|
+
const res = await pdFetch(token, `/incidents/${encodeURIComponent(incidentId)}`);
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
throw new Error(`PagerDuty pre-state ${String(res.status)}: ${res.text.slice(0, 400)}`);
|
|
102
|
+
}
|
|
103
|
+
const inc = (res.json as { incident?: Record<string, unknown> } | null)?.incident ?? {};
|
|
104
|
+
return {
|
|
105
|
+
id: incidentId,
|
|
106
|
+
status: inc["status"],
|
|
107
|
+
assignments: inc["assignments"],
|
|
108
|
+
escalation_level: inc["escalation_level"] ?? null,
|
|
109
|
+
escalation_policy: inc["escalation_policy"],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
45
113
|
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
46
114
|
export const PAGERDUTY_TOOL_NAMES = [
|
|
47
115
|
"pd_incident_list",
|
|
@@ -51,13 +119,6 @@ export const PAGERDUTY_TOOL_NAMES = [
|
|
|
51
119
|
"pd_incident_escalate",
|
|
52
120
|
] as const;
|
|
53
121
|
|
|
54
|
-
/** The incident status changes, which differ only in the word they PUT. */
|
|
55
|
-
const INCIDENT_STATUS_ACTIONS = [
|
|
56
|
-
{ action: "acknowledge", description: "Acknowledge an incident." },
|
|
57
|
-
{ action: "resolve", description: "Resolve an incident." },
|
|
58
|
-
{ action: "escalate", description: "Escalate an incident." },
|
|
59
|
-
] as const;
|
|
60
|
-
|
|
61
122
|
export function registerPagerdutyTools(
|
|
62
123
|
server: ConsentServer & { tool: (...args: never) => unknown },
|
|
63
124
|
): void {
|
|
@@ -107,20 +168,54 @@ export function registerPagerdutyTools(
|
|
|
107
168
|
(parsed) => `/incidents/${encodeURIComponent(parsed.incidentId)}`,
|
|
108
169
|
);
|
|
109
170
|
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
171
|
+
// Literal tool ids and `mutates` strings, so a static scan reads them. None can be undone
|
|
172
|
+
// through these tools (there is no unacknowledge, reopen or de-escalate), so each records the
|
|
173
|
+
// incident's prior state in the audit.
|
|
174
|
+
const incidentArgs = z.object({ incidentId: incidentIdSchema });
|
|
175
|
+
const incidentScope = (p: { incidentId: string }) => ({
|
|
176
|
+
kind: "incident",
|
|
177
|
+
value: p.incidentId,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
registerWriteTool(
|
|
181
|
+
"pd_incident_acknowledge",
|
|
182
|
+
{
|
|
183
|
+
mutates: "pagerduty.incident.acknowledge",
|
|
184
|
+
recoverable: false,
|
|
185
|
+
capturePreState: (p) => readIncidentPreState(p.incidentId),
|
|
186
|
+
scopeTargetOf: incidentScope,
|
|
187
|
+
},
|
|
188
|
+
"Acknowledge an incident (`PUT /incidents/{id}` with status acknowledged). Needs PAGERDUTY_FROM_EMAIL, the email of a valid PagerDuty user.",
|
|
189
|
+
incidentArgs,
|
|
190
|
+
async (p) => pdIncidentPut("acknowledge", { status: "acknowledged" }, p.incidentId),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
registerWriteTool(
|
|
194
|
+
"pd_incident_resolve",
|
|
195
|
+
{
|
|
196
|
+
mutates: "pagerduty.incident.resolve",
|
|
197
|
+
recoverable: false,
|
|
198
|
+
capturePreState: (p) => readIncidentPreState(p.incidentId),
|
|
199
|
+
scopeTargetOf: incidentScope,
|
|
200
|
+
},
|
|
201
|
+
"Resolve an incident (`PUT /incidents/{id}` with status resolved); it cannot be reopened through these tools. Needs PAGERDUTY_FROM_EMAIL, the email of a valid PagerDuty user.",
|
|
202
|
+
incidentArgs,
|
|
203
|
+
async (p) => pdIncidentPut("resolve", { status: "resolved" }, p.incidentId),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
registerWriteTool(
|
|
207
|
+
"pd_incident_escalate",
|
|
208
|
+
{
|
|
209
|
+
mutates: "pagerduty.incident.escalate",
|
|
210
|
+
recoverable: false,
|
|
211
|
+
capturePreState: (p) => readIncidentPreState(p.incidentId),
|
|
212
|
+
scopeTargetOf: incidentScope,
|
|
213
|
+
},
|
|
214
|
+
// `escalation_level` ("Escalate the incident to this level in the escalation policy.", an
|
|
215
|
+
// integer) is a property of the `incident` body of PUT /incidents/{id} in PagerDuty's OpenAPI
|
|
216
|
+
// schema (github.com/PagerDuty/api-schema, reference/REST/openapiv3.json, retrieved 2026-11-06).
|
|
217
|
+
"Escalate an incident to the given level of its escalation policy (`PUT /incidents/{id}` with `escalation_level`); it cannot be de-escalated through these tools. Needs PAGERDUTY_FROM_EMAIL, the email of a valid PagerDuty user.",
|
|
218
|
+
incidentArgs.extend({ escalationLevel: z.number().int().min(1).max(20) }),
|
|
219
|
+
async (p) => pdIncidentPut("escalate", { escalation_level: p.escalationLevel }, p.incidentId),
|
|
220
|
+
);
|
|
126
221
|
}
|