@nimbus-dev/connectors 0.8.0 → 0.10.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.
Files changed (31) hide show
  1. package/connectors/bitrise/nimbus.extension.json +2 -2
  2. package/connectors/bitrise/src/server.ts +12 -4
  3. package/connectors/bitrise/src/tools.ts +138 -7
  4. package/connectors/codemagic/nimbus.extension.json +2 -2
  5. package/connectors/codemagic/src/server.ts +12 -4
  6. package/connectors/codemagic/src/tools.ts +130 -6
  7. package/connectors/firebase/nimbus.extension.json +2 -2
  8. package/connectors/firebase/src/server.ts +12 -4
  9. package/connectors/firebase/src/tools.ts +155 -9
  10. package/connectors/iac/nimbus.extension.json +1 -1
  11. package/connectors/iac/src/tools.ts +808 -105
  12. package/connectors/kubernetes/nimbus.extension.json +1 -1
  13. package/connectors/kubernetes/src/manifest-scan.ts +602 -0
  14. package/connectors/kubernetes/src/tools.ts +331 -2
  15. package/connectors/netlify/nimbus.extension.json +2 -2
  16. package/connectors/netlify/src/server.ts +12 -4
  17. package/connectors/netlify/src/tools.ts +158 -8
  18. package/connectors/testflight/nimbus.extension.json +2 -2
  19. package/connectors/testflight/src/server.ts +12 -4
  20. package/connectors/testflight/src/tools.ts +160 -6
  21. package/connectors/vercel/nimbus.extension.json +2 -2
  22. package/connectors/vercel/src/server.ts +12 -4
  23. package/connectors/vercel/src/tools.ts +161 -7
  24. package/package.json +1 -1
  25. package/shared/cap-text.ts +15 -0
  26. package/shared/consent-kit.ts +15 -0
  27. package/shared/env-json-api.ts +24 -6
  28. package/shared/prepare-tool-ids.ts +13 -4
  29. package/shared/private-kubeconfig.ts +10 -9
  30. package/shared/private-scratch.ts +68 -0
  31. package/shared/run-cli-json.ts +17 -1
@@ -1,7 +1,13 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
1
4
  import { z } from "zod";
2
5
  import { cliArg } from "../../../shared/cli-json-kit.ts";
3
6
  import type { ConsentServer } from "../../../shared/consent-kit.ts";
4
- import { createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
7
+ import {
8
+ createPrepareToolRegistrar,
9
+ createWriteToolRegistrar,
10
+ } from "../../../shared/consent-kit.ts";
5
11
  import { isKubectlNotFound, projectDeployment } from "../../../shared/k8s-deployment-view.ts";
6
12
  import {
7
13
  createRegisterSimpleTool,
@@ -9,7 +15,9 @@ import {
9
15
  mcpJsonResult as jsonResult,
10
16
  } from "../../../shared/mcp-tool-kit.ts";
11
17
  import { withPrivateKubeconfig } from "../../../shared/private-kubeconfig.ts";
12
- import { runCliJsonThrowing, runCliOkThrowing } from "../../../shared/run-cli-json.ts";
18
+ import { withScratchDir } from "../../../shared/private-scratch.ts";
19
+ import { runCliCode, runCliJsonThrowing, runCliOkThrowing } from "../../../shared/run-cli-json.ts";
20
+ import { type ScannedObject, scanNamespacedManifest } from "./manifest-scan.ts";
13
21
 
14
22
  function kubectlBase(): string[] {
15
23
  const args = ["kubectl"];
@@ -33,6 +41,185 @@ function kubectlJson(rest: string[]): Promise<unknown> {
33
41
  return withKube((env) => runCliJsonThrowing(cmd, env));
34
42
  }
35
43
 
44
+ /** A manifest may be at most this many UTF-8 bytes (the schema's character cap is a floor of it). */
45
+ const MANIFEST_MAX_BYTES = 262_144;
46
+ /** The diff in a plan reply; the owner approves what the preview shows, so a bigger one is refused. */
47
+ const DIFF_MAX_BYTES = 65_536;
48
+ /** The whole plan reply, which the gateway refuses above this size. */
49
+ const PLAN_REPLY_MAX_BYTES = 262_144;
50
+ /** Domain separator of the plan hash: sha256 of this, a newline, the namespace, a newline, the manifest. */
51
+ const PLAN_HASH_DOMAIN = "nimbus-k8s-manifest-v1";
52
+ const MISMATCH = "manifest does not match the approved hash";
53
+
54
+ interface ManifestInput {
55
+ readonly namespace: string;
56
+ readonly manifest: string;
57
+ }
58
+
59
+ /**
60
+ * The checks every manifest tool makes before it touches a file or a process, in this order: size
61
+ * in UTF-8 bytes, the namespaced-subset scan (manifest-scan.ts), and the hash of the exact bytes
62
+ * kubectl will be handed. Throws a fixed message.
63
+ */
64
+ function checkedManifest(p: ManifestInput): {
65
+ bytes: Buffer;
66
+ sha256: string;
67
+ scanned: ScannedObject[];
68
+ } {
69
+ const bytes = Buffer.from(p.manifest, "utf8");
70
+ if (bytes.length > MANIFEST_MAX_BYTES) {
71
+ throw new Error(`manifest is larger than ${String(MANIFEST_MAX_BYTES)} bytes`);
72
+ }
73
+ const scanned = scanNamespacedManifest(p.manifest, p.namespace);
74
+ // The hash binds the namespace to the manifest, so an approved plan for one namespace cannot be
75
+ // replayed against another. The kube CONTEXT is owner environment (KUBE_CONTEXT), not caller
76
+ // input, and is deliberately not part of it.
77
+ const hashed = [PLAN_HASH_DOMAIN, p.namespace, p.manifest].join("\n");
78
+ const sha256 = createHash("sha256").update(hashed, "utf8").digest("hex");
79
+ return { bytes, sha256, scanned };
80
+ }
81
+
82
+ /** {@link checkedManifest}, then the apply's own check: the manifest must hash to what was approved. */
83
+ function approvedManifest(p: ManifestInput & { readonly planSha256: string }) {
84
+ const checked = checkedManifest(p);
85
+ const want = Buffer.from(p.planSha256, "hex");
86
+ const have = Buffer.from(checked.sha256, "hex");
87
+ // Equal length is checked first: timingSafeEqual throws on unequal buffers. The schema pins 64 hex.
88
+ if (want.length !== have.length || !timingSafeEqual(want, have)) {
89
+ throw new Error(MISMATCH);
90
+ }
91
+ return checked;
92
+ }
93
+
94
+ function refusalOf(check: () => unknown): string | undefined {
95
+ try {
96
+ check();
97
+ return undefined;
98
+ } catch (e) {
99
+ return e instanceof Error ? e.message : String(e);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * kubectl's failure as an error. A Secret in the manifest means kubectl's stderr may quote its
105
+ * data (an invalid value is often echoed), so the snippet is withheld whenever one is present.
106
+ */
107
+ function kubectlFailure(
108
+ label: string,
109
+ code: number,
110
+ stderr: string,
111
+ scanned: readonly ScannedObject[],
112
+ ): Error {
113
+ const detail = scanned.some((o) => o.kind === "Secret")
114
+ ? "(stderr withheld: the manifest holds a Secret)"
115
+ : stderr.slice(0, 500);
116
+ return new Error(`${label} exited ${String(code)}: ${detail}`);
117
+ }
118
+
119
+ /** What a plan's `diff` holds in place of kubectl's when the manifest holds a Secret. */
120
+ export const SECRET_DIFF_WITHHELD = "(diff withheld: the manifest holds a Secret)";
121
+
122
+ /** The `kubectl ... -o name` resource prefix of each allowlisted kind. */
123
+ const RESOURCE_PREFIX: Readonly<Record<string, string>> = {
124
+ ConfigMap: "configmap",
125
+ Secret: "secret",
126
+ Service: "service",
127
+ Pod: "pod",
128
+ ServiceAccount: "serviceaccount",
129
+ PersistentVolumeClaim: "persistentvolumeclaim",
130
+ Deployment: "deployment.apps",
131
+ StatefulSet: "statefulset.apps",
132
+ DaemonSet: "daemonset.apps",
133
+ ReplicaSet: "replicaset.apps",
134
+ Job: "job.batch",
135
+ CronJob: "cronjob.batch",
136
+ Ingress: "ingress.networking.k8s.io",
137
+ NetworkPolicy: "networkpolicy.networking.k8s.io",
138
+ Role: "role.rbac.authorization.k8s.io",
139
+ RoleBinding: "rolebinding.rbac.authorization.k8s.io",
140
+ HorizontalPodAutoscaler: "horizontalpodautoscaler.autoscaling",
141
+ PodDisruptionBudget: "poddisruptionbudget.policy",
142
+ };
143
+
144
+ /**
145
+ * Order by UTF-16 code unit, the same on every machine. Not `localeCompare`: collation can tie two
146
+ * distinct strings, and these lists are compared element by element.
147
+ */
148
+ function compareCodeUnits(a: string, b: string): number {
149
+ if (a < b) return -1;
150
+ return a > b ? 1 : 0;
151
+ }
152
+
153
+ /** The sorted `<prefix>/<name>` list the scan expects kubectl to report. */
154
+ function expectedNames(scanned: readonly ScannedObject[]): string[] {
155
+ return scanned
156
+ .map((o) => `${RESOURCE_PREFIX[o.kind] ?? o.kind}/${o.name}`)
157
+ .sort(compareCodeUnits);
158
+ }
159
+
160
+ /**
161
+ * Defence in depth behind the scanner: what kubectl itself reports (`-o name`) must be, as a
162
+ * multiset, exactly what the scan read. A document kubectl reads differently (a List flattened into
163
+ * its items, a kind the scan mis-paired) shows up here as a different set, before the diff and
164
+ * before the real apply. Names only, so the message is safe to show.
165
+ */
166
+ function assertSameObjects(
167
+ reported: readonly string[],
168
+ scanned: readonly ScannedObject[],
169
+ when: string,
170
+ ): void {
171
+ const have = [...reported].sort(compareCodeUnits);
172
+ const want = expectedNames(scanned);
173
+ if (have.length !== want.length || have.some((n, i) => n !== want[i])) {
174
+ throw new Error(
175
+ `kubectl ${when} reported different objects than the manifest scan (kubectl: ${have.join(", ")}; scan: ${want.join(", ")})`,
176
+ );
177
+ }
178
+ }
179
+
180
+ /** Write the manifest into the call's private scratch directory: exclusive create, owner-only. */
181
+ async function writeManifestFile(dir: string, bytes: Buffer): Promise<string> {
182
+ const file = join(dir, "manifest.yaml");
183
+ await writeFile(file, bytes, { flag: "wx", mode: 0o600 });
184
+ return file;
185
+ }
186
+
187
+ /** `kubectl apply --server-side --dry-run=server -o name`, cross-checked against the scan. */
188
+ async function serverDryRun(
189
+ kube: { readonly KUBECONFIG: string },
190
+ ns: string,
191
+ file: string,
192
+ scanned: readonly ScannedObject[],
193
+ ): Promise<string[]> {
194
+ const dry = await runCliCode(
195
+ [
196
+ ...kubectlBase(),
197
+ "apply",
198
+ "--server-side",
199
+ "--dry-run=server",
200
+ "--field-manager=nimbus",
201
+ "-n",
202
+ ns,
203
+ "-f",
204
+ file,
205
+ "-o",
206
+ "name",
207
+ ],
208
+ kube,
209
+ );
210
+ if (dry.code !== 0) throw kubectlFailure("kubectl", dry.code, dry.stderr, scanned);
211
+ const objects = objectLines(dry.stdout);
212
+ assertSameObjects(objects, scanned, "dry run");
213
+ return objects;
214
+ }
215
+
216
+ function objectLines(stdout: string): string[] {
217
+ return stdout
218
+ .split("\n")
219
+ .map((l) => l.trim())
220
+ .filter((l) => l !== "");
221
+ }
222
+
36
223
  const optionalNamespaceSchema = z.object({ namespace: cliArg.optional() });
37
224
 
38
225
  async function k8sListNamespacedResource(p: { namespace?: string | undefined }, resource: string) {
@@ -50,6 +237,8 @@ export const KUBERNETES_TOOL_NAMES = [
50
237
  "k8s_rollout_restart",
51
238
  "k8s_pod_delete",
52
239
  "k8s_deployment_scale",
240
+ "k8s_manifest_plan",
241
+ "k8s_manifest_apply",
53
242
  ] as const;
54
243
 
55
244
  export function registerKubernetesTools(
@@ -68,6 +257,16 @@ export function registerKubernetesTools(
68
257
  scopeKinds: ["namespace"],
69
258
  });
70
259
 
260
+ /**
261
+ * Prepare tools (shared/prepare-tool-ids.ts) read the state a write is bound to, for the gateway
262
+ * to call BEFORE the write's approval prompt; they are neither reads nor writes to the kit.
263
+ */
264
+ const registerPrepareTool = createPrepareToolRegistrar(server, {
265
+ connector: "kubernetes",
266
+ scopeEnv: "NIMBUS_MCP_KUBERNETES_WRITE_SCOPE",
267
+ scopeKinds: ["namespace"],
268
+ });
269
+
71
270
  reg(
72
271
  "k8s_pod_list",
73
272
  "List pods in a namespace (default namespace: default).",
@@ -177,4 +376,134 @@ export function registerKubernetesTools(
177
376
  return jsonResult({ ok: true });
178
377
  },
179
378
  );
379
+
380
+ registerPrepareTool(
381
+ "k8s_manifest_plan",
382
+ { scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }) },
383
+ "Plan a k8s_manifest_apply: scan the manifest (namespaced kinds from an allowlist only, in the given namespace), then ask the API server for a server-side dry run and a server-side diff. Returns {planSha256, namespace, objects, scanned, diff, hasDifferences}; the apply must be given the same namespace, manifest and planSha256. Changes nothing.",
384
+ z.object({ namespace: cliArg, manifest: z.string().min(1).max(MANIFEST_MAX_BYTES) }),
385
+ async (p) => {
386
+ const { bytes, sha256, scanned } = checkedManifest(p);
387
+ const ns = p.namespace;
388
+ const reply = await withScratchDir("nimbus-k8s-", async (dir) => {
389
+ const file = await writeManifestFile(dir, bytes);
390
+ return withKube(async (kube) => {
391
+ // The dry run: the API server validates and admits the manifest and names each object,
392
+ // and the names must be the ones the scan read (see assertSameObjects).
393
+ const objects = await serverDryRun(kube, ns, file, scanned);
394
+
395
+ // kubectl diff runs the program KUBECTL_EXTERNAL_DIFF names (default `diff -u -N`). The
396
+ // variable is removed from the child whatever the owner's shell sets, so the diff is
397
+ // never produced by an arbitrary program. TMPDIR/TMP/TEMP point kubectl's own temp files
398
+ // (Go reads TMP and TEMP on Windows) into this call's private directory. Exit codes:
399
+ // https://kubernetes.io/docs/reference/kubectl/generated/kubectl_diff/ (0 no
400
+ // differences, 1 differences, >1 error). --force-conflicts is never passed.
401
+ const diff = await runCliCode(
402
+ [
403
+ ...kubectlBase(),
404
+ "diff",
405
+ "--server-side",
406
+ "--field-manager=nimbus",
407
+ "-n",
408
+ ns,
409
+ "-f",
410
+ file,
411
+ ],
412
+ {
413
+ KUBECONFIG: kube.KUBECONFIG,
414
+ KUBECTL_EXTERNAL_DIFF: undefined,
415
+ TMPDIR: dir,
416
+ TMP: dir,
417
+ TEMP: dir,
418
+ },
419
+ );
420
+ if (diff.code !== 0 && diff.code !== 1) {
421
+ throw kubectlFailure("kubectl diff", diff.code, diff.stderr, scanned);
422
+ }
423
+ if (Buffer.byteLength(diff.stdout, "utf8") > DIFF_MAX_BYTES) {
424
+ throw new Error(`diff too large (over ${String(DIFF_MAX_BYTES)} bytes)`);
425
+ }
426
+ // kubectl masks a Secret's `data`, but the diff's context lines can still carry its
427
+ // plaintext (a `last-applied-configuration` annotation, `stringData`), so with a Secret
428
+ // in the manifest only the fact of a difference is returned, never the body.
429
+ const holdsSecret = scanned.some((o) => o.kind === "Secret");
430
+ return {
431
+ planSha256: sha256,
432
+ namespace: ns,
433
+ objects,
434
+ scanned,
435
+ diff: holdsSecret ? SECRET_DIFF_WITHHELD : diff.stdout,
436
+ hasDifferences: diff.code === 1,
437
+ };
438
+ });
439
+ });
440
+ if (Buffer.byteLength(JSON.stringify(reply), "utf8") > PLAN_REPLY_MAX_BYTES) {
441
+ throw new Error("plan reply too large");
442
+ }
443
+ return jsonResult(reply);
444
+ },
445
+ );
446
+
447
+ registerWriteTool(
448
+ "k8s_manifest_apply",
449
+ {
450
+ mutates: "kubernetes.manifest.apply",
451
+ // An applied manifest is not undone by this tool; the objects it names are all that remain.
452
+ recoverable: false,
453
+ capturePreState: (p) =>
454
+ Promise.resolve({
455
+ namespace: p.namespace,
456
+ planSha256: p.planSha256,
457
+ objects: checkedManifest(p).scanned,
458
+ }),
459
+ scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
460
+ // Standalone only: a manifest no consent could make safe is refused before any prompt. The
461
+ // handler repeats the same checks, because gateway mode registers the raw handler.
462
+ refuseBefore: (p) => refusalOf(() => approvedManifest(p)),
463
+ },
464
+ "Apply a manifest with a server-side apply, in the given namespace. The namespace and manifest must be the ones k8s_manifest_plan planned (planSha256) and the manifest must hold namespaced objects of allowlisted kinds only. Runs a server-side dry run first and applies only if kubectl reports the objects the scan read.",
465
+ z.object({
466
+ namespace: cliArg,
467
+ manifest: z.string().min(1).max(MANIFEST_MAX_BYTES),
468
+ planSha256: z.string().regex(/^[0-9a-f]{64}$/),
469
+ }),
470
+ async (p) => {
471
+ const { bytes, scanned } = approvedManifest(p);
472
+ return withScratchDir("nimbus-k8s-", async (dir) => {
473
+ const file = await writeManifestFile(dir, bytes);
474
+ return withKube(async (kube) => {
475
+ // Dry run and cross-check FIRST, so an object kubectl would apply that the scan did not
476
+ // read stops the call before anything changes.
477
+ await serverDryRun(kube, p.namespace, file, scanned);
478
+ // The file the real apply reads must still hold the approved bytes: the dry run was a
479
+ // child process with the same file, and on Windows the 0600 mode is not enforced.
480
+ if (!bytes.equals(await readFile(file))) {
481
+ throw new Error("the manifest file changed before the apply");
482
+ }
483
+ const applied = await runCliCode(
484
+ [
485
+ ...kubectlBase(),
486
+ "apply",
487
+ "--server-side",
488
+ "--field-manager=nimbus",
489
+ "-n",
490
+ p.namespace,
491
+ "-f",
492
+ file,
493
+ "-o",
494
+ "name",
495
+ ],
496
+ kube,
497
+ );
498
+ if (applied.code !== 0) {
499
+ throw kubectlFailure("kubectl", applied.code, applied.stderr, scanned);
500
+ }
501
+ const objects = objectLines(applied.stdout);
502
+ // After the fact: the objects are already applied, so this is reported, not prevented.
503
+ assertSameObjects(objects, scanned, "applied and");
504
+ return jsonResult({ ok: true, status: "applied", objects });
505
+ });
506
+ });
507
+ },
508
+ );
180
509
  }
@@ -2,14 +2,14 @@
2
2
  "id": "com.nimbus.netlify",
3
3
  "displayName": "Netlify",
4
4
  "version": "0.1.0",
5
- "description": "Netlify site connector (read-only). Surfaces site id, name, the production URL + SSL URL, the admin dashboard URL, the linked git repo + branch, and the embedded published-deploy status (state / branch / commit ref / commit URL / deploy preview URL) into the local index as `netlify:site` items — useful for answering \"is the latest deploy live?\" and \"which site shipped this commit?\". Bearer-token (PAT) auth. The SaaS host (api.netlify.com) is fixed. Per-deploy history, forms, functions, env vars, and DNS are deferred follow-ups.",
5
+ "description": "Netlify site connector. Surfaces site id, name, the production URL + SSL URL, the admin dashboard URL, the linked git repo + branch, and the embedded published-deploy status (state / branch / commit ref / commit URL / deploy preview URL) into the local index as `netlify:site` items — useful for answering \"is the latest deploy live?\" and \"which site shipped this commit?\". Bearer-token (PAT) auth. The SaaS host (api.netlify.com) is fixed. Write tool (needs owner approval): `netlify.build.trigger`; a build with no branch is a PRODUCTION deploy. Forms, functions, env vars, and DNS are deferred follow-ups.",
6
6
  "author": "Nimbus",
7
7
  "entrypoint": "dist/server.js",
8
8
  "runtime": "bun",
9
9
  "permissions": {
10
10
  "network": ["api.netlify.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 { registerNetlifyTools } from "./tools.ts";
3
5
 
4
- await runReadOnlyMcpConnector("nimbus-netlify", (reg) => {
5
- registerNetlifyTools(reg);
6
- });
6
+ const server = new McpServer(
7
+ { name: "nimbus-netlify", version: "0.1.0" },
8
+ { capabilities: CONSENT_SERVER_CAPABILITIES },
9
+ );
10
+
11
+ registerNetlifyTools(server);
12
+
13
+ const transport = new StdioServerTransport();
14
+ await server.connect(transport);
@@ -1,22 +1,93 @@
1
1
  import { z } from "zod";
2
- import { createJsonGetter, envAuthHeaders } from "../../../shared/env-json-api.ts";
2
+ import { capText } from "../../../shared/cap-text.ts";
3
+ import { type ConsentServer, createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
4
+ import {
5
+ createJsonGetter,
6
+ createJsonSender,
7
+ envAuthHeaders,
8
+ type JsonApiConfig,
9
+ } from "../../../shared/env-json-api.ts";
10
+ import { isSafeGitRef } from "../../../shared/github-rest.ts";
3
11
  import { matchesResult, searchToolInputSchema } from "../../../shared/mcp-search-tool.ts";
4
- import { mcpJsonResult as jsonResult } from "../../../shared/mcp-tool-kit.ts";
5
- import type { ZodToolRegistrar } from "../../../shared/run-read-only-mcp-connector.ts";
12
+ import {
13
+ createRegisterSimpleTool,
14
+ createZodToolRegistrar,
15
+ mcpJsonResult as jsonResult,
16
+ } from "../../../shared/mcp-tool-kit.ts";
6
17
  import { filterNetlifySites } from "./search-filter.ts";
7
18
 
8
19
  const BASE = "https://api.netlify.com";
9
20
 
10
- const netlifyGet = createJsonGetter({
21
+ const api: JsonApiConfig = {
11
22
  base: BASE,
12
23
  label: "Netlify",
13
24
  headers: envAuthHeaders({ env: "NETLIFY_TOKEN" }),
14
- });
25
+ };
26
+
27
+ const netlifyGet = createJsonGetter(api);
28
+ // A mutation's 3xx is refused (never followed), so the token is not replayed anywhere.
29
+ const netlifySend = createJsonSender(api);
15
30
 
16
31
  /** Tool names exposed by this connector — for contract/introspection tests. */
17
- export const NETLIFY_TOOL_NAMES = ["netlify_get", "netlify_list", "netlify_search"] as const;
32
+ export const NETLIFY_TOOL_NAMES = [
33
+ "netlify_list",
34
+ "netlify_get",
35
+ "netlify_search",
36
+ "netlify_deploy_list",
37
+ "netlify_build_trigger",
38
+ ] as const;
39
+
40
+ /** A site id (a UUID) or name: never `.` or `..` (a URL parser collapses them out of the path). */
41
+ const siteIdArg = z
42
+ .string()
43
+ .regex(/^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/, "letters, digits, '.', '_' and '-' only");
44
+
45
+ type Obj = Record<string, unknown>;
46
+
47
+ function asObj(v: unknown): Obj {
48
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Obj) : {};
49
+ }
50
+
51
+ function str(v: unknown): string | undefined {
52
+ return typeof v === "string" && v !== "" ? v : undefined;
53
+ }
54
+
55
+ interface TriggerArgs {
56
+ readonly siteId: string;
57
+ readonly branch?: string | undefined;
58
+ readonly production?: true | undefined;
59
+ readonly clearCache?: boolean | undefined;
60
+ }
61
+
62
+ /**
63
+ * Why a build trigger is refused before anything is sent, or `undefined`. An omitted `branch`
64
+ * publishes the PRODUCTION site, and the owner's preview shows the input only, so that choice must
65
+ * be written in the input: `production: true`. Shared by `refuseBefore` (standalone) and the
66
+ * handler (gateway mode registers the raw handler).
67
+ */
68
+ function triggerProblem(p: TriggerArgs): string | undefined {
69
+ if (p.branch !== undefined && p.production === true) {
70
+ return "give a branch or production: true, not both; nothing was sent";
71
+ }
72
+ if (p.branch === undefined && p.production !== true) {
73
+ return "name a branch, or pass production: true to publish the production site; nothing was sent";
74
+ }
75
+ if (p.branch !== undefined && !isSafeGitRef(p.branch)) {
76
+ return "branch is not a safe git ref; nothing was sent";
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ export function registerNetlifyTools(
82
+ server: ConsentServer & { tool: (...args: never) => unknown },
83
+ ): void {
84
+ const reg = createZodToolRegistrar(createRegisterSimpleTool(server));
85
+ const registerWriteTool = createWriteToolRegistrar(server, {
86
+ connector: "netlify",
87
+ scopeEnv: "NIMBUS_MCP_NETLIFY_WRITE_SCOPE",
88
+ scopeKinds: ["site"],
89
+ });
18
90
 
19
- export function registerNetlifyTools(reg: ZodToolRegistrar): void {
20
91
  reg(
21
92
  "netlify_list",
22
93
  "List Netlify sites (`GET /api/v1/sites`), capped at `per_page` (default 100). Returns a bare JSON array of site objects, each carrying the embedded `published_deploy` status and `build_settings`.",
@@ -31,7 +102,7 @@ export function registerNetlifyTools(reg: ZodToolRegistrar): void {
31
102
 
32
103
  reg(
33
104
  "netlify_get",
34
- "Fetch one Netlify site by its id (`GET /api/v1/sites/{siteId}`). Returns the site object directly. Throws when no match is found.",
105
+ "Fetch one Netlify site by its id (`GET /api/v1/sites/{siteId}`). Returns the site object directly, including `published_deploy` (the live deploy). Throws when no match is found.",
35
106
  z.object({
36
107
  siteId: z.string().min(1),
37
108
  }),
@@ -50,4 +121,83 @@ export function registerNetlifyTools(reg: ZodToolRegistrar): void {
50
121
  return matchesResult(root, filterNetlifySites, p);
51
122
  },
52
123
  );
124
+
125
+ reg(
126
+ "netlify_deploy_list",
127
+ "List a Netlify site's most recent deploys (`GET /api/v1/sites/{siteId}/deploys`, newest first), at most `perPage` (default 5, max 10), each projected to `{ id, state, branch, context, commit_ref, created_at, published_at, error_message }`. `context` says whether it was a production deploy.",
128
+ z.object({
129
+ siteId: siteIdArg,
130
+ perPage: z.number().int().min(1).max(10).optional(),
131
+ }),
132
+ async (p) => {
133
+ const search = new URLSearchParams({ per_page: String(p.perPage ?? 5), page: "1" });
134
+ const root = await netlifyGet(
135
+ `/api/v1/sites/${encodeURIComponent(p.siteId)}/deploys?${search.toString()}`,
136
+ );
137
+ const deploys = Array.isArray(root) ? root : [];
138
+ return jsonResult(
139
+ deploys.map((raw) => {
140
+ const d = asObj(raw);
141
+ return {
142
+ id: capText(d["id"]),
143
+ state: capText(d["state"]),
144
+ branch: capText(d["branch"]),
145
+ context: capText(d["context"]),
146
+ commit_ref: capText(d["commit_ref"]),
147
+ created_at: capText(d["created_at"]),
148
+ published_at: capText(d["published_at"]),
149
+ error_message: capText(d["error_message"]),
150
+ };
151
+ }),
152
+ );
153
+ },
154
+ );
155
+
156
+ registerWriteTool(
157
+ "netlify_build_trigger",
158
+ {
159
+ mutates: "netlify.build.trigger",
160
+ // A build adds a deploy; it does not delete one, and a production deploy is replaced by the next one.
161
+ recoverable: true,
162
+ scopeTargetOf: (p: { siteId: string }) => ({ kind: "site", value: p.siteId }),
163
+ refuseBefore: (p: TriggerArgs) => triggerProblem(p),
164
+ },
165
+ "Start a Netlify build of a site (`POST /api/v1/sites/{siteId}/builds`, query `branch` and `clear_cache`, NO body). IMPORTANT: Netlify treats a build with no branch as a PRODUCTION deploy and publishes it when it succeeds, so you MUST either name a `branch` (a branch that is not the site's production branch makes a branch deploy) or pass `production: true` to publish the production site on purpose; neither, or both, sends nothing. `clearCache: true` discards the build cache first. The reply is `accepted` with the build id: Netlify builds it afterwards, so check `netlify_deploy_list`.",
166
+ z.object({
167
+ siteId: siteIdArg,
168
+ branch: z.string().min(1).max(255).optional(),
169
+ production: z.literal(true).optional(),
170
+ clearCache: z.boolean().optional(),
171
+ }),
172
+ async (p) => {
173
+ const problem = triggerProblem(p);
174
+ if (problem !== undefined) throw new Error(problem);
175
+ // Netlify documents these as QUERY parameters, so nothing goes in a body.
176
+ const search = new URLSearchParams();
177
+ if (p.branch !== undefined) {
178
+ search.set("branch", p.branch);
179
+ }
180
+ if (p.clearCache === true) {
181
+ search.set("clear_cache", "true");
182
+ }
183
+ const qs = search.toString();
184
+ const reply = asObj(
185
+ await netlifySend(
186
+ "POST",
187
+ `/api/v1/sites/${encodeURIComponent(p.siteId)}/builds${qs === "" ? "" : `?${qs}`}`,
188
+ undefined,
189
+ ),
190
+ );
191
+ return jsonResult({
192
+ ok: true,
193
+ status: "accepted",
194
+ siteId: p.siteId,
195
+ // No branch = a production deploy (documented), and only `production: true` gets here without one.
196
+ branch: p.branch ?? null,
197
+ production: p.branch === undefined,
198
+ buildId: str(reply["id"]) ?? null,
199
+ deployId: str(reply["deploy_id"]) ?? null,
200
+ });
201
+ },
202
+ );
53
203
  }
@@ -2,14 +2,14 @@
2
2
  "id": "com.nimbus.testflight",
3
3
  "displayName": "TestFlight",
4
4
  "version": "0.1.0",
5
- "description": "TestFlight connector via Apple's App Store Connect API (read-only). Surfaces apps and recent builds (version, processing state, uploaded date, min OS, encryption flag) so the local index can correlate iOS/macOS beta builds back to releases and incidents.",
5
+ "description": "TestFlight connector via Apple's App Store Connect API. Surfaces apps and recent builds (version, processing state, uploaded date, min OS, encryption flag) so the local index can correlate iOS/macOS beta builds back to releases and incidents. Write tool (needs owner approval): `testflight.build.distribute`, which adds a build to beta groups.",
6
6
  "author": "Nimbus",
7
7
  "entrypoint": "dist/server.js",
8
8
  "runtime": "bun",
9
9
  "permissions": {
10
10
  "network": ["api.appstoreconnect.apple.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 { registerTestflightTools } from "./tools.ts";
3
5
 
4
- await runReadOnlyMcpConnector("nimbus-testflight", (reg) => {
5
- registerTestflightTools(reg);
6
- });
6
+ const server = new McpServer(
7
+ { name: "nimbus-testflight", version: "0.1.0" },
8
+ { capabilities: CONSENT_SERVER_CAPABILITIES },
9
+ );
10
+
11
+ registerTestflightTools(server);
12
+
13
+ const transport = new StdioServerTransport();
14
+ await server.connect(transport);