@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.
@@ -2,14 +2,14 @@
2
2
  "id": "com.nimbus.flagsmith",
3
3
  "displayName": "Flagsmith",
4
4
  "version": "0.1.0",
5
- "description": "Flagsmith feature-flag connector (read-only). Surfaces feature-flag definitions across all projects — name, type, default-enabled, initial value, description, tags, archived state, owner count, and timestamps — into the local index as `flagsmith:feature_flag` items, useful for incident correlation (\"did this flag exist when the alert fired?\"). Admin-API-token auth (`Authorization: Token <token>`). Regional / self-hosted instances override `flagsmith.api_base` (default https://api.flagsmith.com); the SaaS API host (api.flagsmith.com) is the only one in the static sandbox manifest today. v1 indexes flag definitions only — per-environment on/off state + segments are a deferred follow-up. The `flagsmith.flag.toggle` write tool is a deferred Phase 8 follow-up.",
5
+ "description": "Flagsmith feature-flag connector. Surfaces feature-flag definitions across all projects — name, type, default-enabled, initial value, description, tags, archived state, owner count, and timestamps — into the local index as `flagsmith:feature_flag` items, useful for incident correlation (\"did this flag exist when the alert fired?\"). Admin-API-token auth (`Authorization: Token <token>`). Regional / self-hosted instances override `flagsmith.api_base` (default https://api.flagsmith.com); the SaaS API host (api.flagsmith.com) is the only one in the static sandbox manifest today. v1 indexes flag definitions only — per-environment on/off state + segments are a deferred follow-up. Write tool (needs owner approval): `flagsmith.feature.state.set` enables or disables a feature, optionally with a typed value, in ONE environment, through Flagsmith's experimental `__future__` endpoint; Flagsmith refuses it while change requests are enabled.",
6
6
  "author": "Nimbus",
7
7
  "entrypoint": "dist/server.js",
8
8
  "runtime": "bun",
9
9
  "permissions": {
10
10
  "network": ["api.flagsmith.com"]
11
11
  },
12
- "hitlRequired": [],
12
+ "hitlRequired": ["write"],
13
13
  "syncInterval": 600,
14
14
  "minNimbusVersion": "0.2.0"
15
15
  }
@@ -1,6 +1,14 @@
1
- import { runReadOnlyMcpConnector } from "../../../shared/run-read-only-mcp-connector.ts";
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 { registerFlagsmithTools } from "./tools.ts";
3
5
 
4
- await runReadOnlyMcpConnector("nimbus-flagsmith", (reg) => {
5
- registerFlagsmithTools(reg);
6
- });
6
+ const server = new McpServer(
7
+ { name: "nimbus-flagsmith", version: "0.1.0" },
8
+ { capabilities: CONSENT_SERVER_CAPABILITIES },
9
+ );
10
+
11
+ registerFlagsmithTools(server);
12
+
13
+ const transport = new StdioServerTransport();
14
+ await server.connect(transport);
@@ -1,17 +1,52 @@
1
1
  import { z } from "zod";
2
- import { createJsonGetter, envAuthHeaders, optionalEnv } from "../../../shared/env-json-api.ts";
3
- import { mcpJsonResult as jsonResult } from "../../../shared/mcp-tool-kit.ts";
4
- import type { ZodToolRegistrar } from "../../../shared/run-read-only-mcp-connector.ts";
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";
10
+ import {
11
+ createRegisterSimpleTool,
12
+ createZodToolRegistrar,
13
+ mcpJsonResult as jsonResult,
14
+ } from "../../../shared/mcp-tool-kit.ts";
5
15
  import { filterFlagsmithFeatures } from "./search-filter.ts";
6
16
 
7
17
  const DEFAULT_API_BASE = "https://api.flagsmith.com";
8
18
 
9
- const fsGet = createJsonGetter({
19
+ /**
20
+ * `Authorization: Token <FLAGSMITH_TOKEN>` for reads and writes alike. Flagsmith's swagger lists
21
+ * this scheme ("tokenAuth", prefix "Token") on the `__future__` operations too, beside the
22
+ * organisation "Master API Key" scheme; the docs page for those operations shows `Api-Key`, which
23
+ * is that second scheme. One factory, so a switch is one line.
24
+ */
25
+ const flagsmithHeaders = envAuthHeaders({ env: "FLAGSMITH_TOKEN", scheme: "Token" });
26
+
27
+ const apiV1: JsonApiConfig = {
10
28
  // `optionalEnv`, not `optionalBaseUrl`: a trailing slash on the override is left as given.
11
29
  base: () => `${optionalEnv("FLAGSMITH_API_BASE", DEFAULT_API_BASE)}/api/v1`,
12
30
  label: "Flagsmith",
13
- headers: envAuthHeaders({ env: "FLAGSMITH_TOKEN", scheme: "Token" }),
14
- });
31
+ headers: flagsmithHeaders,
32
+ };
33
+
34
+ /**
35
+ * The environment-level feature-state endpoints. EXPERIMENTAL ("may change without notice",
36
+ * https://docs.flagsmith.com/managing-flags/updating-flags). Why not the v1 route: Flagsmith's own
37
+ * repository calls `/api/v1/environments/{key}/featurestates/` "deprecated" (PR 8732, "the legacy
38
+ * feature state APIs"), the docs site documents only the `__future__` pair, and the v1 write needs
39
+ * a feature-state id looked up first. `__future__` takes the feature id directly. Isolated here so
40
+ * a change of path is one edit.
41
+ */
42
+ const apiFuture: JsonApiConfig = {
43
+ ...apiV1,
44
+ base: () => `${optionalEnv("FLAGSMITH_API_BASE", DEFAULT_API_BASE)}/api/__future__`,
45
+ };
46
+
47
+ const fsGet = createJsonGetter(apiV1);
48
+ const fsFutureGet = createJsonGetter(apiFuture);
49
+ const fsFutureSend = createJsonSender(apiFuture);
15
50
 
16
51
  function featuresFromPage(root: unknown): unknown[] {
17
52
  if (Array.isArray(root)) {
@@ -26,9 +61,65 @@ export const FLAGSMITH_TOOL_NAMES = [
26
61
  "flagsmith_list",
27
62
  "flagsmith_get",
28
63
  "flagsmith_search",
64
+ "flagsmith_feature_state_get",
65
+ "flagsmith_feature_state_set",
29
66
  ] as const;
30
67
 
31
- export function registerFlagsmithTools(reg: ZodToolRegistrar): void {
68
+ /** Segment overrides returned by a state read; the rest are counted, not shown (size bound). */
69
+ const MAX_SEGMENT_OVERRIDES = 50;
70
+
71
+ const featureStateArgs = z.object({
72
+ environmentKey: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, "letters, digits, '_' and '-' only"),
73
+ featureId: z.number().int().min(1),
74
+ });
75
+
76
+ type FeatureStateArgs = z.infer<typeof featureStateArgs>;
77
+
78
+ function statePath(p: FeatureStateArgs): string {
79
+ return `/environments/${encodeURIComponent(p.environmentKey)}/features/${String(p.featureId)}/`;
80
+ }
81
+
82
+ /** `{ environment_default, segment_overrides }` with the overrides capped and the cap disclosed. */
83
+ async function readFeatureState(p: FeatureStateArgs): Promise<Record<string, unknown>> {
84
+ const raw = (await fsFutureGet(statePath(p))) as {
85
+ environment_default?: unknown;
86
+ segment_overrides?: unknown;
87
+ } | null;
88
+ const overrides = Array.isArray(raw?.segment_overrides) ? raw.segment_overrides : [];
89
+ return {
90
+ environment_default: raw?.environment_default,
91
+ segment_overrides: overrides.slice(0, MAX_SEGMENT_OVERRIDES),
92
+ ...(overrides.length > MAX_SEGMENT_OVERRIDES
93
+ ? { segmentOverridesNotShown: overrides.length - MAX_SEGMENT_OVERRIDES }
94
+ : {}),
95
+ };
96
+ }
97
+
98
+ const valueSchema = z.object({
99
+ type: z.enum(["string", "integer", "boolean"]),
100
+ value: z.string().max(20_000),
101
+ });
102
+
103
+ /** The typed value must parse as its type; checked in the handler, before any request. */
104
+ function assertValueMatchesType(v: z.infer<typeof valueSchema>): void {
105
+ if (v.type === "integer" && !/^-?\d+$/.test(v.value)) {
106
+ throw new Error(`value ${JSON.stringify(v.value)} is not an integer; nothing was sent`);
107
+ }
108
+ if (v.type === "boolean" && v.value !== "true" && v.value !== "false") {
109
+ throw new Error(`value ${JSON.stringify(v.value)} is not true or false; nothing was sent`);
110
+ }
111
+ }
112
+
113
+ export function registerFlagsmithTools(
114
+ server: ConsentServer & { tool: (...args: never) => unknown },
115
+ ): void {
116
+ const reg = createZodToolRegistrar(createRegisterSimpleTool(server));
117
+ const registerWriteTool = createWriteToolRegistrar(server, {
118
+ connector: "flagsmith",
119
+ scopeEnv: "NIMBUS_MCP_FLAGSMITH_WRITE_SCOPE",
120
+ scopeKinds: ["flag"],
121
+ });
122
+
32
123
  reg(
33
124
  "flagsmith_list",
34
125
  "List Flagsmith projects, or feature flags for a project. Without `projectId`, returns the account's projects (`/projects/`). With `projectId`, returns that project's feature flags (`/projects/{projectId}/features/`), capped at `limit` (default 100).",
@@ -92,4 +183,53 @@ export function registerFlagsmithTools(reg: ZodToolRegistrar): void {
92
183
  return jsonResult({ matches });
93
184
  },
94
185
  );
186
+
187
+ reg(
188
+ "flagsmith_feature_state_get",
189
+ "Read one feature's state in ONE environment (`GET /api/__future__/environments/{environmentKey}/features/{featureId}/`, an EXPERIMENTAL Flagsmith endpoint): `environment_default` (enabled, typed value, variants) and `segment_overrides` (capped at 50, the remainder counted in `segmentOverridesNotShown`). Identity overrides are not covered.",
190
+ featureStateArgs,
191
+ async (p) => jsonResult(await readFeatureState(p)),
192
+ );
193
+
194
+ registerWriteTool(
195
+ "flagsmith_feature_state_set",
196
+ {
197
+ mutates: "flagsmith.feature.state.set",
198
+ // The previous state is recorded in the audit pre-state; this tool does not restore it.
199
+ recoverable: false,
200
+ capturePreState: readFeatureState,
201
+ scopeTargetOf: (p) => ({ kind: "flag", value: `${p.environmentKey}/${String(p.featureId)}` }),
202
+ },
203
+ "Enable or disable a feature in ONE environment, optionally setting its typed value (`PATCH /api/__future__/environments/{environmentKey}/features/{featureId}/`, an EXPERIMENTAL Flagsmith endpoint that may change without notice). Omitting `value` is intended to leave the value alone (PATCH merge semantics are unverified against a live server); segment and identity overrides are untouched. Flagsmith refuses it while change requests are enabled for the environment, with its own message. The result carries `confirmed`: true only when the reply shows the requested `enabled` and, when `value` was given, that value; false means the server accepted the write but its reply does not show the new state.",
204
+ featureStateArgs.extend({ enabled: z.boolean(), value: valueSchema.optional() }),
205
+ async (p) => {
206
+ // Validated here, not with `.refine`: a refined schema loses `.shape` (the registrar needs it).
207
+ if (p.value !== undefined) assertValueMatchesType(p.value);
208
+ const response = (await fsFutureSend("PATCH", statePath(p), {
209
+ environment_default: {
210
+ enabled: p.enabled,
211
+ ...(p.value === undefined ? {} : { value: p.value }),
212
+ },
213
+ })) as { environment_default?: { enabled?: unknown; value?: unknown } } | null;
214
+ const echoed = response?.environment_default;
215
+ // The value comes back as `{type, value}` like it is sent; its inner value may be typed or a
216
+ // string, so the string forms are compared, and a type the reply names must be the one sent.
217
+ const shown = echoed?.value as { type?: unknown; value?: unknown } | undefined;
218
+ const valueShown =
219
+ p.value === undefined ||
220
+ (shown !== undefined &&
221
+ shown !== null &&
222
+ String(shown.value) === p.value.value &&
223
+ (shown.type === undefined || shown.type === p.value.type));
224
+ return jsonResult({
225
+ ok: true,
226
+ environmentKey: p.environmentKey,
227
+ featureId: p.featureId,
228
+ // true only when the reply shows the state that was asked for; false means the server
229
+ // accepted the write but its reply does not show the new state.
230
+ confirmed: echoed?.enabled === p.enabled && valueShown,
231
+ enabled: echoed?.enabled,
232
+ });
233
+ },
234
+ );
95
235
  }
@@ -4,7 +4,15 @@ import {
4
4
  createWriteToolRegistrar,
5
5
  type WriteToolConfig,
6
6
  } from "../../../shared/consent-kit.ts";
7
- import { ghFetch, ghQueryPath, ghRepoPath, setGhPaging } from "../../../shared/github-rest.ts";
7
+ import {
8
+ ghFetch,
9
+ ghQueryPath,
10
+ ghRepoPath,
11
+ isSafeGhSlug,
12
+ isSafeGitRef,
13
+ resolveCommitSha,
14
+ setGhPaging,
15
+ } from "../../../shared/github-rest.ts";
8
16
  import {
9
17
  createRegisterSimpleTool,
10
18
  createZodToolRegistrar,
@@ -35,6 +43,10 @@ export const GITHUB_TOOL_NAMES = [
35
43
  "github_ci_runs",
36
44
  "github_ci_run_get",
37
45
  "github_branch_delete",
46
+ "github_ref_get",
47
+ "github_branch_create",
48
+ "github_pr_create",
49
+ "github_pr_update",
38
50
  "github_tag_create",
39
51
  "github_commit_push",
40
52
  ] as const;
@@ -61,6 +73,11 @@ export function registerGithubTools(
61
73
  const registerGithubTool = makeRestToolRegistrar({ registrar: reg, ...githubRest });
62
74
  const registerRestWriteTool = makeRestWriteToolRegistrar({ registerWriteTool, ...githubRest });
63
75
 
76
+ const repoScope = (p: { owner: string; repo: string }): { kind: string; value: string } => ({
77
+ kind: "repo",
78
+ value: `${p.owner}/${p.repo}`,
79
+ });
80
+
64
81
  /**
65
82
  * The write-tool equivalent of `registerGithubTool`, routed through the write registrar.
66
83
  * `scopeTargetOf` is supplied here rather than per tool — every GitHub mutation is scoped to one
@@ -76,7 +93,7 @@ export function registerGithubTools(
76
93
  ): void {
77
94
  registerRestWriteTool(
78
95
  name,
79
- { ...cfg, scopeTargetOf: (p) => ({ kind: "repo", value: `${p.owner}/${p.repo}` }) },
96
+ { ...cfg, scopeTargetOf: repoScope },
80
97
  description,
81
98
  schema,
82
99
  buildPath,
@@ -91,8 +108,8 @@ export function registerGithubTools(
91
108
  });
92
109
 
93
110
  const repoSlugArgs = z.object({
94
- owner: z.string().min(1),
95
- repo: z.string().min(1),
111
+ owner: z.string().refine(isSafeGhSlug, "not a valid GitHub owner name"),
112
+ repo: z.string().refine(isSafeGhSlug, "not a valid GitHub repository name"),
96
113
  });
97
114
 
98
115
  const githubRepoListSchema = z.object({
@@ -327,6 +344,119 @@ export function registerGithubTools(
327
344
  },
328
345
  );
329
346
 
347
+ /** A ref the model names reaches a URL path, so every one passes `isSafeGitRef` at the schema. */
348
+ const gitRefSchema = z.string().min(1).max(255).refine(isSafeGitRef, "not a safe git ref name");
349
+
350
+ const githubRefGetSchema = repoSlugArgs.extend({ ref: gitRefSchema });
351
+
352
+ reg(
353
+ "github_ref_get",
354
+ "Resolve a branch, tag or commit to its commit SHA (a bare name is tried as a branch, then a tag; an annotated tag resolves to its commit). Returns {ref, kind: branch|tag|sha, sha}.",
355
+ githubRefGetSchema,
356
+ async (parsed) => {
357
+ const token = requireProcessEnv("GITHUB_PAT");
358
+ const { sha, kind } = await resolveCommitSha(token, parsed.owner, parsed.repo, parsed.ref);
359
+ return jsonResult({ ref: parsed.ref, kind, sha });
360
+ },
361
+ );
362
+
363
+ const githubBranchCreateSchema = repoSlugArgs.extend({
364
+ branch: gitRefSchema,
365
+ fromRef: gitRefSchema,
366
+ expectedSha: z
367
+ .string()
368
+ .regex(/^[0-9a-f]{40}$/i)
369
+ .optional(),
370
+ });
371
+
372
+ registerWriteTool(
373
+ "github_branch_create",
374
+ // A new ref is undone by github_branch_delete, so there is nothing to capture first.
375
+ { mutates: "github.branch.create", recoverable: true, scopeTargetOf: repoScope },
376
+ "Create a branch from a branch, tag or commit (fromRef). If expectedSha is given and fromRef now resolves to a different commit, nothing is created.",
377
+ githubBranchCreateSchema,
378
+ async (parsed) => {
379
+ const token = requireProcessEnv("GITHUB_PAT");
380
+ const { sha } = await resolveCommitSha(token, parsed.owner, parsed.repo, parsed.fromRef);
381
+ if (parsed.expectedSha !== undefined && parsed.expectedSha.toLowerCase() !== sha) {
382
+ throw new Error(`fromRef resolves to ${sha}, not the approved ${parsed.expectedSha}`);
383
+ }
384
+ const ref = `refs/heads/${parsed.branch}`;
385
+ const res = await ghFetch(
386
+ token,
387
+ `${ghRepoPath(parsed.owner, parsed.repo)}/git/refs`,
388
+ jsonInit("POST", { ref, sha }),
389
+ );
390
+ if (!res.ok) {
391
+ throw new Error(`GitHub ${String(res.status)}: ${res.text.slice(0, 300)}`);
392
+ }
393
+ return jsonResult({ ok: true, ref, sha, fromRef: parsed.fromRef });
394
+ },
395
+ );
396
+
397
+ const githubPrCreateSchema = repoSlugArgs.extend({
398
+ head: z.string().min(1).max(255),
399
+ base: z.string().min(1).max(255),
400
+ title: z.string().min(1).max(256),
401
+ body: z.string().max(65_000).optional(),
402
+ draft: z.boolean().optional(),
403
+ });
404
+
405
+ registerGithubWriteTool(
406
+ "github_pr_create",
407
+ { mutates: "github.pr.create", recoverable: true },
408
+ "Open a pull request from head (a branch, or user:branch for a fork) into base. Title and body are sent exactly as given.",
409
+ githubPrCreateSchema,
410
+ (parsed) => `${ghRepoPath(parsed.owner, parsed.repo)}/pulls`,
411
+ (parsed) =>
412
+ jsonInit("POST", {
413
+ head: parsed.head,
414
+ base: parsed.base,
415
+ title: parsed.title,
416
+ ...(parsed.body === undefined ? {} : { body: parsed.body }),
417
+ ...(parsed.draft === undefined ? {} : { draft: parsed.draft }),
418
+ }),
419
+ );
420
+
421
+ // A plain object, not a `.refine()`d one: the registrar passes `.shape` as the MCP inputSchema and a
422
+ // refined object has none. The at-least-one check is the handler's.
423
+ const githubPrUpdateSchema = githubPrNumberSchema.extend({
424
+ title: z.string().min(1).max(256).optional(),
425
+ body: z.string().max(65_000).optional(),
426
+ base: z.string().min(1).max(255).optional(),
427
+ });
428
+ const prPath = (parsed: { owner: string; repo: string; pullNumber: number }): string =>
429
+ `${ghRepoPath(parsed.owner, parsed.repo)}/pulls/${String(parsed.pullNumber)}`;
430
+
431
+ registerGithubWriteTool(
432
+ "github_pr_update",
433
+ {
434
+ mutates: "github.pr.update",
435
+ // An edit overwrites text; its inverse needs the old text, so capture it first.
436
+ recoverable: false,
437
+ capturePreState: async (parsed) => {
438
+ const token = requireProcessEnv("GITHUB_PAT");
439
+ const res = await ghFetch(token, prPath(parsed));
440
+ const pr = res.json as { title?: unknown; body?: unknown; base?: { ref?: unknown } } | null;
441
+ if (!res.ok || pr === null) return { resolved: false, status: res.status };
442
+ return { title: pr.title, body: pr.body, base: pr.base?.ref };
443
+ },
444
+ },
445
+ "Edit a pull request's title, body or base branch (at least one). It never opens, closes or merges it.",
446
+ githubPrUpdateSchema,
447
+ prPath,
448
+ (parsed) => {
449
+ if (parsed.title === undefined && parsed.body === undefined && parsed.base === undefined) {
450
+ throw new Error("github_pr_update needs at least one of title, body, base");
451
+ }
452
+ return jsonInit("PATCH", {
453
+ ...(parsed.title === undefined ? {} : { title: parsed.title }),
454
+ ...(parsed.body === undefined ? {} : { body: parsed.body }),
455
+ ...(parsed.base === undefined ? {} : { base: parsed.base }),
456
+ });
457
+ },
458
+ );
459
+
330
460
  const githubTagCreateSchema = repoSlugArgs.extend({
331
461
  tag: z.string().min(1).max(255),
332
462
  sha: z.string().min(7).max(40),
@@ -8,6 +8,7 @@ import {
8
8
  ghFetch,
9
9
  ghQueryPath,
10
10
  ghRepoPath,
11
+ isSafeGhSlug,
11
12
  setGhPaging,
12
13
  } from "../../../shared/github-rest.ts";
13
14
  import {
@@ -54,8 +55,8 @@ export function registerGithubActionsTools(
54
55
  });
55
56
 
56
57
  const repoSlugArgs = z.object({
57
- owner: z.string().min(1),
58
- repo: z.string().min(1),
58
+ owner: z.string().refine(isSafeGhSlug, "not a valid GitHub owner name"),
59
+ repo: z.string().refine(isSafeGhSlug, "not a valid GitHub repository name"),
59
60
  });
60
61
 
61
62
  const runListSchema = repoSlugArgs.extend({