@nimbus-dev/connectors 0.2.1 → 0.2.2

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 (215) hide show
  1. package/README.md +14 -4
  2. package/connectors/airflow/src/search-filter.ts +2 -15
  3. package/connectors/airflow/src/server.ts +2 -87
  4. package/connectors/airflow/src/tools.ts +74 -0
  5. package/connectors/apple/src/adapters.ts +326 -0
  6. package/connectors/apple/src/apple-mail-core.ts +4 -8
  7. package/connectors/apple/src/calendar-tools.ts +28 -8
  8. package/connectors/apple/src/server.ts +23 -512
  9. package/connectors/apple/src/tools.ts +18 -44
  10. package/connectors/argocd/src/server.ts +25 -38
  11. package/connectors/athena/src/tools.ts +15 -34
  12. package/connectors/aws/src/server.ts +2 -183
  13. package/connectors/aws/src/tools.ts +197 -0
  14. package/connectors/azure/src/server.ts +2 -131
  15. package/connectors/azure/src/tools.ts +133 -0
  16. package/connectors/bigeye/src/search-filter.ts +4 -15
  17. package/connectors/bigeye/src/server.ts +38 -38
  18. package/connectors/bigquery/src/tools.ts +1 -26
  19. package/connectors/bitbucket/src/server.ts +2 -244
  20. package/connectors/bitbucket/src/tools.ts +241 -0
  21. package/connectors/bitrise/src/server.ts +2 -79
  22. package/connectors/bitrise/src/tools.ts +76 -0
  23. package/connectors/canva/src/search-filter.ts +2 -11
  24. package/connectors/canva/src/server.ts +2 -73
  25. package/connectors/canva/src/tools.ts +66 -0
  26. package/connectors/circleci/src/server.ts +2 -176
  27. package/connectors/circleci/src/tools.ts +193 -0
  28. package/connectors/cloud-logging/src/tools.ts +14 -27
  29. package/connectors/cloudwatch/src/tools.ts +15 -34
  30. package/connectors/codemagic/src/server.ts +2 -70
  31. package/connectors/codemagic/src/tools.ts +71 -0
  32. package/connectors/confluence/src/server.ts +2 -301
  33. package/connectors/confluence/src/tools.ts +320 -0
  34. package/connectors/dagster/src/server.ts +2 -214
  35. package/connectors/dagster/src/tools.ts +187 -0
  36. package/connectors/databricks/src/search-filter.ts +7 -7
  37. package/connectors/databricks/src/server.ts +2 -77
  38. package/connectors/databricks/src/tools.ts +66 -0
  39. package/connectors/datadog/src/server.ts +2 -48
  40. package/connectors/datadog/src/tools.ts +48 -0
  41. package/connectors/dataprofile/src/profile.ts +16 -46
  42. package/connectors/dbt/src/server.ts +2 -92
  43. package/connectors/dbt/src/tools.ts +80 -0
  44. package/connectors/dependencytrack/src/server.ts +2 -76
  45. package/connectors/dependencytrack/src/tools.ts +65 -0
  46. package/connectors/discord/src/server.ts +2 -85
  47. package/connectors/discord/src/tools.ts +82 -0
  48. package/connectors/elasticsearch/src/tools.ts +7 -37
  49. package/connectors/fastmail/src/jmap-client.ts +202 -0
  50. package/connectors/fastmail/src/jmap-core.ts +8 -21
  51. package/connectors/fastmail/src/server.ts +2 -187
  52. package/connectors/fastmail/src/tools.ts +4 -23
  53. package/connectors/figma/src/search-filter.ts +2 -11
  54. package/connectors/figma/src/server.ts +2 -113
  55. package/connectors/figma/src/tools.ts +99 -0
  56. package/connectors/firebase/src/server.ts +2 -90
  57. package/connectors/firebase/src/token.ts +2 -5
  58. package/connectors/firebase/src/tools.ts +106 -0
  59. package/connectors/flagsmith/src/server.ts +2 -98
  60. package/connectors/flagsmith/src/tools.ts +95 -0
  61. package/connectors/flux/src/server.ts +52 -53
  62. package/connectors/gcp/src/server.ts +2 -132
  63. package/connectors/gcp/src/tools.ts +123 -0
  64. package/connectors/github/src/server.ts +2 -328
  65. package/connectors/github/src/tools.ts +311 -0
  66. package/connectors/github-actions/src/server.ts +2 -205
  67. package/connectors/github-actions/src/tools.ts +209 -0
  68. package/connectors/gitlab/src/server.ts +2 -384
  69. package/connectors/gitlab/src/tools.ts +392 -0
  70. package/connectors/gmail/src/server.ts +2 -270
  71. package/connectors/gmail/src/tools.ts +209 -0
  72. package/connectors/google-drive/src/server.ts +2 -524
  73. package/connectors/google-drive/src/tools.ts +529 -0
  74. package/connectors/google-meet/src/server.ts +2 -82
  75. package/connectors/google-meet/src/tools.ts +87 -0
  76. package/connectors/google-photos/src/server.ts +2 -132
  77. package/connectors/google-photos/src/tools.ts +138 -0
  78. package/connectors/grafana/src/server.ts +2 -53
  79. package/connectors/grafana/src/tools.ts +53 -0
  80. package/connectors/great-expectations/src/gx-parse.ts +14 -45
  81. package/connectors/greenhouse/src/server.ts +2 -63
  82. package/connectors/greenhouse/src/tools.ts +68 -0
  83. package/connectors/hubspot/src/server.ts +2 -77
  84. package/connectors/hubspot/src/tools.ts +70 -0
  85. package/connectors/iac/src/server.ts +2 -137
  86. package/connectors/iac/src/tools.ts +152 -0
  87. package/connectors/imap/src/imap-core.ts +29 -82
  88. package/connectors/imap/src/server.ts +33 -220
  89. package/connectors/intercom/src/server.ts +2 -61
  90. package/connectors/intercom/src/tools.ts +58 -0
  91. package/connectors/jenkins/src/jenkins-api.ts +2 -6
  92. package/connectors/jenkins/src/server.ts +2 -215
  93. package/connectors/jenkins/src/tools.ts +240 -0
  94. package/connectors/jira/src/server.ts +2 -301
  95. package/connectors/jira/src/tools.ts +325 -0
  96. package/connectors/kubernetes/src/server.ts +2 -155
  97. package/connectors/kubernetes/src/tools.ts +159 -0
  98. package/connectors/launchdarkly/src/server.ts +2 -75
  99. package/connectors/launchdarkly/src/tools.ts +72 -0
  100. package/connectors/lever/src/search-filter.ts +4 -12
  101. package/connectors/lever/src/server.ts +2 -60
  102. package/connectors/lever/src/tools.ts +61 -0
  103. package/connectors/linear/src/server.ts +2 -459
  104. package/connectors/linear/src/tools.ts +482 -0
  105. package/connectors/localdb/src/sql-scan.ts +13 -41
  106. package/connectors/localdb/src/tools.ts +1 -1
  107. package/connectors/looker/src/search-filter.ts +2 -15
  108. package/connectors/looker/src/server.ts +35 -70
  109. package/connectors/mendeley/src/server.ts +2 -55
  110. package/connectors/mendeley/src/tools.ts +54 -0
  111. package/connectors/mercury/src/server.ts +2 -57
  112. package/connectors/mercury/src/tools.ts +50 -0
  113. package/connectors/metabase/src/search-filter.ts +4 -15
  114. package/connectors/metabase/src/server.ts +2 -79
  115. package/connectors/metabase/src/tools.ts +64 -0
  116. package/connectors/miro/src/server.ts +2 -72
  117. package/connectors/miro/src/tools.ts +65 -0
  118. package/connectors/mlflow/src/search-filter.ts +3 -7
  119. package/connectors/mlflow/src/server.ts +20 -40
  120. package/connectors/monte-carlo/src/server.ts +50 -57
  121. package/connectors/netlify/src/search-filter.ts +6 -14
  122. package/connectors/netlify/src/server.ts +2 -60
  123. package/connectors/netlify/src/tools.ts +53 -0
  124. package/connectors/newrelic/src/server.ts +2 -40
  125. package/connectors/newrelic/src/tools.ts +37 -0
  126. package/connectors/notion/src/server.ts +2 -335
  127. package/connectors/notion/src/tools.ts +344 -0
  128. package/connectors/obsidian/src/server.ts +2 -425
  129. package/connectors/obsidian/src/tools.ts +466 -0
  130. package/connectors/onedrive/src/server.ts +2 -249
  131. package/connectors/onedrive/src/tools.ts +253 -0
  132. package/connectors/outlook/src/server.ts +2 -351
  133. package/connectors/outlook/src/tools.ts +357 -0
  134. package/connectors/pagerduty/src/server.ts +2 -132
  135. package/connectors/pagerduty/src/tools.ts +126 -0
  136. package/connectors/pipedrive/src/server.ts +2 -58
  137. package/connectors/pipedrive/src/tools.ts +63 -0
  138. package/connectors/powerbi/src/search-filter.ts +2 -15
  139. package/connectors/powerbi/src/server.ts +43 -68
  140. package/connectors/prefect/src/search-filter.ts +3 -16
  141. package/connectors/prefect/src/server.ts +2 -90
  142. package/connectors/prefect/src/tools.ts +83 -0
  143. package/connectors/protonmail/src/mail-core.ts +16 -75
  144. package/connectors/protonmail/src/server.ts +34 -214
  145. package/connectors/raindrop/src/server.ts +2 -103
  146. package/connectors/raindrop/src/tools.ts +103 -0
  147. package/connectors/ramp/src/server.ts +2 -105
  148. package/connectors/ramp/src/tools.ts +98 -0
  149. package/connectors/readwise/src/server.ts +2 -88
  150. package/connectors/readwise/src/tools.ts +81 -0
  151. package/connectors/sagemaker/src/tools.ts +19 -38
  152. package/connectors/salesforce/src/search-filter.ts +4 -11
  153. package/connectors/salesforce/src/server.ts +2 -90
  154. package/connectors/salesforce/src/tools.ts +75 -0
  155. package/connectors/semgrep/src/search-filter.ts +3 -10
  156. package/connectors/semgrep/src/server.ts +2 -104
  157. package/connectors/semgrep/src/tools.ts +92 -0
  158. package/connectors/sentry/src/server.ts +2 -58
  159. package/connectors/sentry/src/tools.ts +53 -0
  160. package/connectors/slack/src/server.ts +2 -352
  161. package/connectors/slack/src/tools.ts +376 -0
  162. package/connectors/snowflake/src/search-filter.ts +4 -19
  163. package/connectors/snowflake/src/server.ts +6 -25
  164. package/connectors/snyk/src/server.ts +2 -140
  165. package/connectors/snyk/src/tools.ts +136 -0
  166. package/connectors/sonarqube/src/server.ts +2 -111
  167. package/connectors/sonarqube/src/tools.ts +98 -0
  168. package/connectors/stackoverflow/src/server.ts +2 -77
  169. package/connectors/stackoverflow/src/tools.ts +66 -0
  170. package/connectors/storybook/src/storybook-parse.ts +3 -6
  171. package/connectors/stripe/src/server.ts +2 -61
  172. package/connectors/stripe/src/tools.ts +54 -0
  173. package/connectors/superset/src/search-filter.ts +4 -15
  174. package/connectors/superset/src/server.ts +2 -115
  175. package/connectors/superset/src/tools.ts +98 -0
  176. package/connectors/tableau/src/search-filter.ts +2 -15
  177. package/connectors/tableau/src/server.ts +13 -65
  178. package/connectors/teams/src/server.ts +2 -249
  179. package/connectors/teams/src/tools.ts +283 -0
  180. package/connectors/testflight/src/server.ts +2 -70
  181. package/connectors/testflight/src/tools.ts +79 -0
  182. package/connectors/vercel/src/search-filter.ts +2 -10
  183. package/connectors/vercel/src/server.ts +2 -75
  184. package/connectors/vercel/src/tools.ts +73 -0
  185. package/connectors/vertex-ai/src/tools.ts +23 -33
  186. package/connectors/wiz/src/search-filter.ts +6 -30
  187. package/connectors/wiz/src/server.ts +2 -188
  188. package/connectors/wiz/src/tools.ts +168 -0
  189. package/connectors/zendesk/src/server.ts +2 -73
  190. package/connectors/zendesk/src/tools.ts +60 -0
  191. package/connectors/zoom/src/search-filter.ts +1 -7
  192. package/connectors/zoom/src/server.ts +2 -94
  193. package/connectors/zoom/src/tools.ts +93 -0
  194. package/connectors/zotero/src/server.ts +2 -78
  195. package/connectors/zotero/src/tools.ts +67 -0
  196. package/package.json +14 -12
  197. package/shared/access-token-cache.ts +123 -0
  198. package/shared/atlassian-json-fetch.ts +6 -16
  199. package/shared/audit-chain.ts +13 -6
  200. package/shared/cli-json-kit.ts +150 -0
  201. package/shared/collection-tool-kit.ts +143 -0
  202. package/shared/connector-mode.ts +3 -2
  203. package/shared/cursor-list-tool.ts +29 -0
  204. package/shared/env-json-api.ts +209 -0
  205. package/shared/fetch-json-text.ts +27 -0
  206. package/shared/github-rest.ts +62 -0
  207. package/shared/graphql-json.ts +60 -0
  208. package/shared/imap-mail-core.ts +91 -0
  209. package/shared/imap-tool-kit.ts +57 -23
  210. package/shared/imapflow-adapter.ts +417 -0
  211. package/shared/rest-tool-kit.ts +77 -7
  212. package/shared/run-cli-json.ts +38 -16
  213. package/shared/search-filter.ts +58 -18
  214. package/shared/walk-files.ts +118 -0
  215. package/standalone/src/launcher.ts +19 -7
@@ -0,0 +1,99 @@
1
+ import { z } from "zod";
2
+ import { createJsonGetter, envAuthHeaders, requiredEnv } from "../../../shared/env-json-api.ts";
3
+ import { 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";
6
+ import { filterFigmaFiles } from "./search-filter.ts";
7
+
8
+ const BASE = "https://api.figma.com";
9
+
10
+ const figmaGet = createJsonGetter({
11
+ base: BASE,
12
+ label: "Figma",
13
+ headers: envAuthHeaders({ env: "FIGMA_TOKEN" }),
14
+ });
15
+
16
+ function projectsFrom(root: unknown): Array<{ id: string; name: string }> {
17
+ const projects = (root as { projects?: unknown } | null)?.projects;
18
+ if (!Array.isArray(projects)) {
19
+ return [];
20
+ }
21
+ const out: Array<{ id: string; name: string }> = [];
22
+ for (const p of projects) {
23
+ const rec = p as { id?: unknown; name?: unknown } | null;
24
+ const id = rec?.id;
25
+ if (typeof id === "string" || typeof id === "number") {
26
+ out.push({
27
+ id: String(id),
28
+ name: typeof rec?.name === "string" ? rec.name : "",
29
+ });
30
+ }
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function filesFrom(root: unknown): unknown[] {
36
+ const files = (root as { files?: unknown } | null)?.files;
37
+ return Array.isArray(files) ? files : [];
38
+ }
39
+
40
+ /**
41
+ * Flatten the configured team's projects into a single list of files, tagging
42
+ * each file with its `project_name` for searchability. This is the two-level
43
+ * fetch: GET /v1/teams/<team_id>/projects, then GET /v1/projects/<id>/files for
44
+ * each project.
45
+ */
46
+ async function listTeamFiles(): Promise<unknown[]> {
47
+ const teamId = encodeURIComponent(requiredEnv("FIGMA_TEAM_ID"));
48
+ const projectsRoot = await figmaGet(`/v1/teams/${teamId}/projects`);
49
+ const projects = projectsFrom(projectsRoot);
50
+ const out: unknown[] = [];
51
+ for (const project of projects) {
52
+ const filesRoot = await figmaGet(`/v1/projects/${encodeURIComponent(project.id)}/files`); // NOSONAR S9382: one request per team project, one at a time on purpose — the project count is unbounded and Figma's REST API is rate-limited, so a fan-out would turn a large team into 429s.
53
+ for (const f of filesFrom(filesRoot)) {
54
+ const rec = f as Record<string, unknown> | null;
55
+ out.push({
56
+ ...rec,
57
+ project_id: project.id,
58
+ project_name: project.name,
59
+ });
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /** Tool names exposed by this connector — for contract/introspection tests. */
66
+ export const FIGMA_TOOL_NAMES = ["figma_get", "figma_list", "figma_search"] as const;
67
+
68
+ export function registerFigmaTools(reg: ZodToolRegistrar): void {
69
+ reg(
70
+ "figma_list",
71
+ "List the configured team's Figma files. Two-level fetch: GET /v1/teams/<team_id>/projects (the team id is injected at spawn from the figma.team_id vault key) returns { name, projects: [{ id, name }] }; then for each project GET /v1/projects/<project_id>/files returns { name, files: [{ key, name, thumbnail_url, last_modified }] }. Returns a flattened { files: [...] } envelope where each file is shaped { key, name, thumbnail_url, last_modified, project_id, project_name }. Neither endpoint paginates — each returns the full list.",
72
+ z.object({}),
73
+ async () => {
74
+ return jsonResult({ files: await listTeamFiles() });
75
+ },
76
+ );
77
+
78
+ reg(
79
+ "figma_get",
80
+ "List the files in one Figma project by its id (GET /v1/projects/{projectId}/files). Returns the { name, files: [{ key, name, thumbnail_url, last_modified }] } envelope directly. Throws when no project with that id is accessible.",
81
+ z.object({
82
+ projectId: z.string().min(1),
83
+ }),
84
+ async (p) => {
85
+ return jsonResult(await figmaGet(`/v1/projects/${encodeURIComponent(p.projectId)}/files`));
86
+ },
87
+ );
88
+
89
+ reg(
90
+ "figma_search",
91
+ "Substring search across the configured team's Figma files. Runs the same two-level fetch as figma_list (team projects, then files per project), flattens the result, and matches the query locally (case-insensitive) against each file name and its project name. Returns a { matches: [...] } envelope.",
92
+ searchToolInputSchema(200),
93
+ async (p) => {
94
+ const files = await listTeamFiles();
95
+ const matches = filterFigmaFiles(files, { query: p.query, limit: p.limit });
96
+ return jsonResult({ matches });
97
+ },
98
+ );
99
+ }
@@ -1,94 +1,6 @@
1
- import { mintGoogleAccessToken } from "@nimbus-dev/sdk";
2
- import { z } from "zod";
3
- import { matchesResult } from "../../../shared/mcp-search-tool.ts";
4
- import { mcpJsonResult as jsonResult } from "../../../shared/mcp-tool-kit.ts";
5
1
  import { runReadOnlyMcpConnector } from "../../../shared/run-read-only-mcp-connector.ts";
6
- import { filterFirebaseReleases } from "./search-filter.ts";
7
- import { configuredAppIds, projectNumberFromAppId, serviceAccountFromEnv } from "./token.ts";
8
-
9
- const APP_DISTRIBUTION_API = "https://firebaseappdistribution.googleapis.com";
10
-
11
- // App Distribution OAuth2 tokens last ~1h; cache and refresh conservatively.
12
- let cachedToken: { token: string; expiresAt: number } | null = null;
13
-
14
- async function accessToken(): Promise<string> {
15
- const now = Date.now();
16
- if (cachedToken !== null && cachedToken.expiresAt > now + 60_000) {
17
- return cachedToken.token;
18
- }
19
- const token = await mintGoogleAccessToken(serviceAccountFromEnv());
20
- if (token === null) {
21
- throw new Error("failed to mint a Firebase access token");
22
- }
23
- cachedToken = { token, expiresAt: now + 30 * 60 * 1000 };
24
- return token;
25
- }
26
-
27
- async function appDistGet(path: string): Promise<unknown> {
28
- const res = await fetch(`${APP_DISTRIBUTION_API}${path}`, {
29
- headers: { Authorization: `Bearer ${await accessToken()}`, Accept: "application/json" },
30
- });
31
- const text = await res.text();
32
- if (!res.ok) {
33
- throw new Error(`Firebase App Distribution ${String(res.status)}: ${text.slice(0, 400)}`);
34
- }
35
- return JSON.parse(text) as unknown;
36
- }
37
-
38
- function appBasePath(appId: string): string {
39
- const projectNumber = projectNumberFromAppId(appId);
40
- if (projectNumber === null) {
41
- throw new Error(`cannot derive a project number from app id: ${appId}`);
42
- }
43
- return `/v1/projects/${encodeURIComponent(projectNumber)}/apps/${encodeURIComponent(appId)}`;
44
- }
45
-
46
- function releasesPath(appId: string, pageSize: number): string {
47
- return `${appBasePath(appId)}/releases?pageSize=${String(pageSize)}`;
48
- }
49
-
50
- function releasePath(appId: string, releaseId: string): string {
51
- return `${appBasePath(appId)}/releases/${encodeURIComponent(releaseId)}`;
52
- }
2
+ import { registerFirebaseTools } from "./tools.ts";
53
3
 
54
4
  await runReadOnlyMcpConnector("nimbus-firebase", (reg) => {
55
- reg(
56
- "firebase_list",
57
- "List Firebase App Distribution resources. When `appId` is omitted, returns the connector's configured app ids. When `appId` is provided, returns that app's most-recent releases via GET /v1/projects/<n>/apps/<appId>/releases (pageSize optional, default 50).",
58
- z.object({
59
- appId: z.string().min(1).optional(),
60
- pageSize: z.number().int().min(1).max(100).optional(),
61
- }),
62
- async (p) => {
63
- if (p.appId === undefined) {
64
- return jsonResult({ appIds: configuredAppIds() });
65
- }
66
- return jsonResult(await appDistGet(releasesPath(p.appId, p.pageSize ?? 50)));
67
- },
68
- );
69
-
70
- reg(
71
- "firebase_get",
72
- "Fetch a single Firebase App Distribution release via GET /v1/projects/<n>/apps/<appId>/releases/<releaseId>.",
73
- z.object({
74
- appId: z.string().min(1),
75
- releaseId: z.string().min(1),
76
- }),
77
- async (p) => jsonResult(await appDistGet(releasePath(p.appId, p.releaseId))),
78
- );
79
-
80
- reg(
81
- "firebase_search",
82
- "Substring search across an app's recent releases. Matches the query against each release's display version, build version, release notes, and resource name (case-insensitive). Returns a `{ matches: [...] }` envelope.",
83
- z.object({
84
- appId: z.string().min(1),
85
- query: z.string().min(1),
86
- limit: z.number().int().min(1).max(100).optional(),
87
- }),
88
- async (p) => {
89
- const root = await appDistGet(releasesPath(p.appId, 100));
90
- const releases = (root as { releases?: unknown[] } | null)?.releases;
91
- return matchesResult(releases, filterFirebaseReleases, p);
92
- },
93
- );
5
+ registerFirebaseTools(reg);
94
6
  });
@@ -1,4 +1,5 @@
1
1
  import { type GoogleServiceAccount, parseServiceAccountJson } from "@nimbus-dev/sdk";
2
+ import { requiredEnv } from "../../../shared/env-json-api.ts";
2
3
 
3
4
  /**
4
5
  * Firebase App Distribution credential plumbing.
@@ -21,11 +22,7 @@ export function projectNumberFromAppId(appId: string): string | null {
21
22
  * throws if unset or malformed. The Gateway sets this at spawn time.
22
23
  */
23
24
  export function serviceAccountFromEnv(): GoogleServiceAccount {
24
- const json = process.env["FIREBASE_SERVICE_ACCOUNT_JSON"]?.trim();
25
- if (json === undefined || json === "") {
26
- throw new Error("FIREBASE_SERVICE_ACCOUNT_JSON is not set");
27
- }
28
- const sa = parseServiceAccountJson(json);
25
+ const sa = parseServiceAccountJson(requiredEnv("FIREBASE_SERVICE_ACCOUNT_JSON"));
29
26
  if (sa === null) {
30
27
  throw new Error("FIREBASE_SERVICE_ACCOUNT_JSON is not a valid service-account key");
31
28
  }
@@ -0,0 +1,106 @@
1
+ import { mintGoogleAccessToken } from "@nimbus-dev/sdk";
2
+ import { z } from "zod";
3
+ import { matchesResult } 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";
6
+ import { filterFirebaseReleases } from "./search-filter.ts";
7
+ import { configuredAppIds, projectNumberFromAppId, serviceAccountFromEnv } from "./token.ts";
8
+
9
+ const APP_DISTRIBUTION_API = "https://firebaseappdistribution.googleapis.com";
10
+
11
+ // App Distribution OAuth2 tokens last ~1h; cache and refresh conservatively.
12
+ let cachedToken: { token: string; expiresAt: number } | null = null;
13
+
14
+ /**
15
+ * Forget the cached access token. Tests only: the cache is module state shared by every test in
16
+ * the process, and a token one test left behind lets a later tool call skip reading its
17
+ * credential entirely.
18
+ */
19
+ export function __resetFirebaseTokenCacheForTests(): void {
20
+ cachedToken = null;
21
+ }
22
+
23
+ async function accessToken(): Promise<string> {
24
+ const now = Date.now();
25
+ if (cachedToken !== null && cachedToken.expiresAt > now + 60_000) {
26
+ return cachedToken.token;
27
+ }
28
+ const token = await mintGoogleAccessToken(serviceAccountFromEnv());
29
+ if (token === null) {
30
+ throw new Error("failed to mint a Firebase access token");
31
+ }
32
+ cachedToken = { token, expiresAt: now + 30 * 60 * 1000 };
33
+ return token;
34
+ }
35
+
36
+ async function appDistGet(path: string): Promise<unknown> {
37
+ const res = await fetch(`${APP_DISTRIBUTION_API}${path}`, {
38
+ headers: { Authorization: `Bearer ${await accessToken()}`, Accept: "application/json" },
39
+ });
40
+ const text = await res.text();
41
+ if (!res.ok) {
42
+ throw new Error(`Firebase App Distribution ${String(res.status)}: ${text.slice(0, 400)}`);
43
+ }
44
+ return JSON.parse(text) as unknown;
45
+ }
46
+
47
+ function appBasePath(appId: string): string {
48
+ const projectNumber = projectNumberFromAppId(appId);
49
+ if (projectNumber === null) {
50
+ throw new Error(`cannot derive a project number from app id: ${appId}`);
51
+ }
52
+ return `/v1/projects/${encodeURIComponent(projectNumber)}/apps/${encodeURIComponent(appId)}`;
53
+ }
54
+
55
+ function releasesPath(appId: string, pageSize: number): string {
56
+ return `${appBasePath(appId)}/releases?pageSize=${String(pageSize)}`;
57
+ }
58
+
59
+ function releasePath(appId: string, releaseId: string): string {
60
+ return `${appBasePath(appId)}/releases/${encodeURIComponent(releaseId)}`;
61
+ }
62
+
63
+ /** Tool names exposed by this connector — for contract/introspection tests. */
64
+ export const FIREBASE_TOOL_NAMES = ["firebase_get", "firebase_list", "firebase_search"] as const;
65
+
66
+ export function registerFirebaseTools(reg: ZodToolRegistrar): void {
67
+ reg(
68
+ "firebase_list",
69
+ "List Firebase App Distribution resources. When `appId` is omitted, returns the connector's configured app ids. When `appId` is provided, returns that app's most-recent releases via GET /v1/projects/<n>/apps/<appId>/releases (pageSize optional, default 50).",
70
+ z.object({
71
+ appId: z.string().min(1).optional(),
72
+ pageSize: z.number().int().min(1).max(100).optional(),
73
+ }),
74
+ async (p) => {
75
+ if (p.appId === undefined) {
76
+ return jsonResult({ appIds: configuredAppIds() });
77
+ }
78
+ return jsonResult(await appDistGet(releasesPath(p.appId, p.pageSize ?? 50)));
79
+ },
80
+ );
81
+
82
+ reg(
83
+ "firebase_get",
84
+ "Fetch a single Firebase App Distribution release via GET /v1/projects/<n>/apps/<appId>/releases/<releaseId>.",
85
+ z.object({
86
+ appId: z.string().min(1),
87
+ releaseId: z.string().min(1),
88
+ }),
89
+ async (p) => jsonResult(await appDistGet(releasePath(p.appId, p.releaseId))),
90
+ );
91
+
92
+ reg(
93
+ "firebase_search",
94
+ "Substring search across an app's recent releases. Matches the query against each release's display version, build version, release notes, and resource name (case-insensitive). Returns a `{ matches: [...] }` envelope.",
95
+ z.object({
96
+ appId: z.string().min(1),
97
+ query: z.string().min(1),
98
+ limit: z.number().int().min(1).max(100).optional(),
99
+ }),
100
+ async (p) => {
101
+ const root = await appDistGet(releasesPath(p.appId, 100));
102
+ const releases = (root as { releases?: unknown[] } | null)?.releases;
103
+ return matchesResult(releases, filterFirebaseReleases, p);
104
+ },
105
+ );
106
+ }
@@ -1,102 +1,6 @@
1
- import { z } from "zod";
2
- import { mcpJsonResult as jsonResult } from "../../../shared/mcp-tool-kit.ts";
3
1
  import { runReadOnlyMcpConnector } from "../../../shared/run-read-only-mcp-connector.ts";
4
- import { filterFlagsmithFeatures } from "./search-filter.ts";
5
-
6
- const DEFAULT_API_BASE = "https://api.flagsmith.com";
7
-
8
- function apiBase(): string {
9
- const v = process.env["FLAGSMITH_API_BASE"]?.trim();
10
- return v === undefined || v === "" ? DEFAULT_API_BASE : v;
11
- }
12
-
13
- function authHeader(): Record<string, string> {
14
- const t = process.env["FLAGSMITH_TOKEN"]?.trim();
15
- if (t === undefined || t === "") {
16
- throw new Error("FLAGSMITH_TOKEN is not set");
17
- }
18
- return { Authorization: `Token ${t}`, Accept: "application/json" };
19
- }
20
-
21
- async function fsGet(path: string): Promise<unknown> {
22
- const res = await fetch(`${apiBase()}/api/v1${path}`, { headers: authHeader() });
23
- const text = await res.text();
24
- if (!res.ok) {
25
- throw new Error(`Flagsmith ${String(res.status)}: ${text.slice(0, 400)}`);
26
- }
27
- return JSON.parse(text) as unknown;
28
- }
29
-
30
- function featuresFromPage(root: unknown): unknown[] {
31
- if (Array.isArray(root)) {
32
- return root;
33
- }
34
- const results = (root as { results?: unknown } | null)?.results;
35
- return Array.isArray(results) ? results : [];
36
- }
2
+ import { registerFlagsmithTools } from "./tools.ts";
37
3
 
38
4
  await runReadOnlyMcpConnector("nimbus-flagsmith", (reg) => {
39
- reg(
40
- "flagsmith_list",
41
- "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).",
42
- z.object({
43
- projectId: z.number().int().optional(),
44
- limit: z.number().int().min(1).max(500).optional(),
45
- }),
46
- async (p) => {
47
- if (p.projectId === undefined) {
48
- return jsonResult(await fsGet("/projects/"));
49
- }
50
- const search = new URLSearchParams({ page_size: String(p.limit ?? 100) });
51
- return jsonResult(
52
- await fsGet(
53
- `/projects/${encodeURIComponent(String(p.projectId))}/features/?${search.toString()}`,
54
- ),
55
- );
56
- },
57
- );
58
-
59
- reg(
60
- "flagsmith_get",
61
- "Fetch one Flagsmith feature flag by project id + feature name. Flagsmith has no GET-by-name endpoint, so this searches the project's features and narrows to the exact-name match. Throws when no match is found.",
62
- z.object({
63
- projectId: z.number().int(),
64
- featureName: z.string().min(1),
65
- }),
66
- async (p) => {
67
- const search = new URLSearchParams({ page_size: "100", search: p.featureName });
68
- const root = await fsGet(
69
- `/projects/${encodeURIComponent(String(p.projectId))}/features/?${search.toString()}`,
70
- );
71
- const match = featuresFromPage(root).find(
72
- (f) =>
73
- f !== null && typeof f === "object" && (f as { name?: unknown }).name === p.featureName,
74
- );
75
- if (match === undefined) {
76
- throw new Error(`Flagsmith feature not found: ${p.featureName}`);
77
- }
78
- return jsonResult(match);
79
- },
80
- );
81
-
82
- reg(
83
- "flagsmith_search",
84
- "Substring search across a project's feature flags. Matches the query against flag name, description, and tags (case-insensitive). Returns a `{ matches: [...] }` envelope.",
85
- z.object({
86
- projectId: z.number().int(),
87
- query: z.string().min(1),
88
- limit: z.number().int().min(1).max(200).optional(),
89
- }),
90
- async (p) => {
91
- const search = new URLSearchParams({ page_size: "500" });
92
- const root = await fsGet(
93
- `/projects/${encodeURIComponent(String(p.projectId))}/features/?${search.toString()}`,
94
- );
95
- const matches = filterFlagsmithFeatures(featuresFromPage(root), {
96
- query: p.query,
97
- limit: p.limit,
98
- });
99
- return jsonResult({ matches });
100
- },
101
- );
5
+ registerFlagsmithTools(reg);
102
6
  });
@@ -0,0 +1,95 @@
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";
5
+ import { filterFlagsmithFeatures } from "./search-filter.ts";
6
+
7
+ const DEFAULT_API_BASE = "https://api.flagsmith.com";
8
+
9
+ const fsGet = createJsonGetter({
10
+ // `optionalEnv`, not `optionalBaseUrl`: a trailing slash on the override is left as given.
11
+ base: () => `${optionalEnv("FLAGSMITH_API_BASE", DEFAULT_API_BASE)}/api/v1`,
12
+ label: "Flagsmith",
13
+ headers: envAuthHeaders({ env: "FLAGSMITH_TOKEN", scheme: "Token" }),
14
+ });
15
+
16
+ function featuresFromPage(root: unknown): unknown[] {
17
+ if (Array.isArray(root)) {
18
+ return root;
19
+ }
20
+ const results = (root as { results?: unknown } | null)?.results;
21
+ return Array.isArray(results) ? results : [];
22
+ }
23
+
24
+ /** Tool names exposed by this connector — for contract/introspection tests. */
25
+ export const FLAGSMITH_TOOL_NAMES = [
26
+ "flagsmith_list",
27
+ "flagsmith_get",
28
+ "flagsmith_search",
29
+ ] as const;
30
+
31
+ export function registerFlagsmithTools(reg: ZodToolRegistrar): void {
32
+ reg(
33
+ "flagsmith_list",
34
+ "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).",
35
+ z.object({
36
+ projectId: z.number().int().optional(),
37
+ limit: z.number().int().min(1).max(500).optional(),
38
+ }),
39
+ async (p) => {
40
+ if (p.projectId === undefined) {
41
+ return jsonResult(await fsGet("/projects/"));
42
+ }
43
+ const search = new URLSearchParams({ page_size: String(p.limit ?? 100) });
44
+ return jsonResult(
45
+ await fsGet(
46
+ `/projects/${encodeURIComponent(String(p.projectId))}/features/?${search.toString()}`,
47
+ ),
48
+ );
49
+ },
50
+ );
51
+
52
+ reg(
53
+ "flagsmith_get",
54
+ "Fetch one Flagsmith feature flag by project id + feature name. Flagsmith has no GET-by-name endpoint, so this searches the project's features and narrows to the exact-name match. Throws when no match is found.",
55
+ z.object({
56
+ projectId: z.number().int(),
57
+ featureName: z.string().min(1),
58
+ }),
59
+ async (p) => {
60
+ const search = new URLSearchParams({ page_size: "100", search: p.featureName });
61
+ const root = await fsGet(
62
+ `/projects/${encodeURIComponent(String(p.projectId))}/features/?${search.toString()}`,
63
+ );
64
+ const match = featuresFromPage(root).find(
65
+ (f) =>
66
+ f !== null && typeof f === "object" && (f as { name?: unknown }).name === p.featureName,
67
+ );
68
+ if (match === undefined) {
69
+ throw new Error(`Flagsmith feature not found: ${p.featureName}`);
70
+ }
71
+ return jsonResult(match);
72
+ },
73
+ );
74
+
75
+ reg(
76
+ "flagsmith_search",
77
+ "Substring search across a project's feature flags. Matches the query against flag name, description, and tags (case-insensitive). Returns a `{ matches: [...] }` envelope.",
78
+ z.object({
79
+ projectId: z.number().int(),
80
+ query: z.string().min(1),
81
+ limit: z.number().int().min(1).max(200).optional(),
82
+ }),
83
+ async (p) => {
84
+ const search = new URLSearchParams({ page_size: "500" });
85
+ const root = await fsGet(
86
+ `/projects/${encodeURIComponent(String(p.projectId))}/features/?${search.toString()}`,
87
+ );
88
+ const matches = filterFlagsmithFeatures(featuresFromPage(root), {
89
+ query: p.query,
90
+ limit: p.limit,
91
+ });
92
+ return jsonResult({ matches });
93
+ },
94
+ );
95
+ }
@@ -1,6 +1,7 @@
1
1
  import { FLUX_KINDS, type FluxKindEntry, trimTrailingSlash } from "@nimbus-dev/sdk";
2
2
  import { z } from "zod";
3
3
  import { type ConsentServer, createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
4
+ import { createJsonGetter, envAuthHeaders, requiredEnv } from "../../../shared/env-json-api.ts";
4
5
  import { fetchWithTimeout, mcpJsonResult as jsonResult } from "../../../shared/mcp-tool-kit.ts";
5
6
  import {
6
7
  runReadOnlyMcpConnector,
@@ -18,30 +19,24 @@ function kindEntry(kind: string): FluxKindEntry {
18
19
  return found;
19
20
  }
20
21
 
22
+ /** Only ONE trailing slash is dropped here — not `requiredBaseUrl`'s strip-them-all. */
21
23
  function apiBase(): string {
22
- const v = process.env["FLUX_API_URL"]?.trim();
23
- if (v === undefined || v === "") {
24
- throw new Error("FLUX_API_URL is not set");
25
- }
26
- return trimTrailingSlash(v);
24
+ return trimTrailingSlash(requiredEnv("FLUX_API_URL"));
27
25
  }
28
26
 
29
- function authHeader(): Record<string, string> {
30
- const t = process.env["FLUX_TOKEN"]?.trim();
31
- if (t === undefined || t === "") {
32
- throw new Error("FLUX_TOKEN is not set");
33
- }
34
- return { Authorization: `Bearer ${t}`, Accept: "application/json" };
35
- }
27
+ /**
28
+ * `fetchWithTimeout`, not the global fetch: this is a self-hosted control plane,
29
+ * and one that stops answering must fail the tool call rather than hang it.
30
+ */
31
+ /** Shared with the mutating request below, which adds its own Content-Type. */
32
+ const authHeader = envAuthHeaders({ env: "FLUX_TOKEN" });
36
33
 
37
- async function agGet(path: string): Promise<unknown> {
38
- const res = await fetchWithTimeout(`${apiBase()}${path}`, { headers: authHeader() });
39
- const text = await res.text();
40
- if (!res.ok) {
41
- throw new Error(`Flux ${String(res.status)}: ${text.slice(0, 400)}`);
42
- }
43
- return JSON.parse(text) as unknown;
44
- }
34
+ const agGet = createJsonGetter({
35
+ base: apiBase,
36
+ label: "Flux",
37
+ headers: authHeader,
38
+ fetch: fetchWithTimeout,
39
+ });
45
40
 
46
41
  function itemsFrom(root: unknown): unknown[] {
47
42
  if (Array.isArray(root)) {
@@ -80,6 +75,24 @@ async function fluxReconcile(kind: string, namespace: string, name: string): Pro
80
75
  return requestedAt;
81
76
  }
82
77
 
78
+ /** The Flux CRs a reconcile can be requested on. */
79
+ const RECONCILABLE = [
80
+ {
81
+ tool: "flux_kustomization_reconcile",
82
+ kind: "kustomization",
83
+ mutates: "flux.kustomization.reconcile",
84
+ description:
85
+ "Request a reconcile of a Flux Kustomization by annotating reconcile.fluxcd.io/requestedAt (PATCH the CR; requires HITL flux.kustomization.reconcile, and the SA's `patch` RBAC verb on kustomizations). Async — verify via the next metadata sync.",
86
+ },
87
+ {
88
+ tool: "flux_helmrelease_reconcile",
89
+ kind: "helm_release",
90
+ mutates: "flux.helmrelease.reconcile",
91
+ description:
92
+ "Request a reconcile of a Flux HelmRelease by annotating reconcile.fluxcd.io/requestedAt (PATCH the CR; requires HITL flux.helmrelease.reconcile, and the SA's `patch` RBAC verb on helmreleases). Async — verify via the next metadata sync.",
93
+ },
94
+ ] as const;
95
+
83
96
  export function registerFluxTools(reg: ZodToolRegistrar, server: unknown): void {
84
97
  // Despite the read-only helper's name, this connector exposes write tools. The consent
85
98
  // kit needs the real server, which the helper now passes through as its second argument.
@@ -140,39 +153,25 @@ export function registerFluxTools(reg: ZodToolRegistrar, server: unknown): void
140
153
  },
141
154
  );
142
155
 
143
- registerWriteTool(
144
- "flux_kustomization_reconcile",
145
- {
146
- mutates: "flux.kustomization.reconcile",
147
- recoverable: true,
148
- scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
149
- },
150
- "Request a reconcile of a Flux Kustomization by annotating reconcile.fluxcd.io/requestedAt (PATCH the CR; requires HITL flux.kustomization.reconcile, and the SA's `patch` RBAC verb on kustomizations). Async — verify via the next metadata sync.",
151
- z.object({ namespace: z.string().min(1), name: z.string().min(1) }),
152
- async (p) =>
153
- jsonResult({
154
- status: "requested",
155
- name: p.name,
156
- requestedAt: await fluxReconcile("kustomization", p.namespace, p.name),
157
- }),
158
- );
159
-
160
- registerWriteTool(
161
- "flux_helmrelease_reconcile",
162
- {
163
- mutates: "flux.helmrelease.reconcile",
164
- recoverable: true,
165
- scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
166
- },
167
- "Request a reconcile of a Flux HelmRelease by annotating reconcile.fluxcd.io/requestedAt (PATCH the CR; requires HITL flux.helmrelease.reconcile, and the SA's `patch` RBAC verb on helmreleases). Async — verify via the next metadata sync.",
168
- z.object({ namespace: z.string().min(1), name: z.string().min(1) }),
169
- async (p) =>
170
- jsonResult({
171
- status: "requested",
172
- name: p.name,
173
- requestedAt: await fluxReconcile("helm_release", p.namespace, p.name),
174
- }),
175
- );
156
+ // Both reconcile tools are the same annotation PATCH against a different CR.
157
+ for (const { tool, kind, mutates, description } of RECONCILABLE) {
158
+ registerWriteTool(
159
+ tool,
160
+ {
161
+ mutates,
162
+ recoverable: true,
163
+ scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
164
+ },
165
+ description,
166
+ z.object({ namespace: z.string().min(1), name: z.string().min(1) }),
167
+ async (p) =>
168
+ jsonResult({
169
+ status: "requested",
170
+ name: p.name,
171
+ requestedAt: await fluxReconcile(kind, p.namespace, p.name),
172
+ }),
173
+ );
174
+ }
176
175
  }
177
176
 
178
177
  export async function startConnector(): Promise<void> {