@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,150 @@
1
+ /**
2
+ * cli-json-kit — the shared pieces of the CLI-backed connectors.
3
+ *
4
+ * Five connectors reach their service by spawning a cloud CLI and parsing its
5
+ * JSON (athena, cloudwatch and sagemaker through `aws`; cloud-logging and
6
+ * vertex-ai through `gcloud`), and each had written out the same three things:
7
+ *
8
+ * - the `cliArg` Zod refinement that rejects an argument starting with `-`
9
+ * (argv flag smuggling) or carrying control characters;
10
+ * - a `runX` wrapper: spawn, trim, throw `"<label> <verb> failed: <stderr>"`
11
+ * on a non-zero exit, otherwise `JSON.parse` the stdout;
12
+ * - `isRecord` / `strField` / `asArray`, the untyped-payload readers used to
13
+ * walk the parsed result.
14
+ *
15
+ * The argument guard is the reason this is worth sharing rather than tolerating:
16
+ * it is a security control, and a security control that exists in five
17
+ * hand-written copies is one that can be strengthened in four of them.
18
+ *
19
+ * The `gcloud` connectors (bigquery, cloud-logging, gcp, vertex-ai) also share
20
+ * how they hand gcloud its project and credentials: see {@link gcloudEnv} and
21
+ * {@link gcloudProjectArgs}.
22
+ */
23
+
24
+ import { z } from "zod";
25
+ import { optionalEnv } from "./env-json-api.ts";
26
+ import { isSafeCliArg } from "./safe-cli-arg.ts";
27
+
28
+ /** Body-snippet length in the thrown error. The value every connector used. */
29
+ export const DEFAULT_STDERR_SNIPPET = 400;
30
+
31
+ /**
32
+ * A value passed to a CLI as an argument.
33
+ *
34
+ * Rejected at the SCHEMA boundary, before the value can reach argv: a leading
35
+ * `-` would be read by the CLI as a flag rather than as the name it is meant to
36
+ * be, and control characters have no legitimate place in one.
37
+ */
38
+ export const cliArg = z
39
+ .string()
40
+ .min(1)
41
+ .refine(isSafeCliArg, { message: 'must not start with "-" or contain control characters' });
42
+
43
+ /** The spawn seam. Production passes `nimbusSpawn`; tests pass a fake. */
44
+ export type SpawnFn = (
45
+ command: readonly string[],
46
+ env: Record<string, string | undefined>,
47
+ ) => Promise<{ code: number; stdout: string; stderr: string }>;
48
+
49
+ export interface CliJsonRunnerConfig {
50
+ /**
51
+ * Builds the full argv for one call. Given the connector's own arguments, it
52
+ * returns the complete command — binary, subcommand, arguments and whatever
53
+ * output flag that CLI needs (`--output json` for aws, `--format json` for
54
+ * gcloud).
55
+ */
56
+ readonly argv: (args: readonly string[]) => readonly string[];
57
+ /** Prefix for the thrown error, e.g. `"aws athena"`. */
58
+ readonly label: string;
59
+ /**
60
+ * What empty stdout means. The `aws` connectors treat it as `{}` and the
61
+ * `gcloud` ones as `[]`, because that is the shape each CLI omits when there
62
+ * is nothing to report.
63
+ */
64
+ readonly emptyResult: unknown;
65
+ /** Extra environment for the spawn. Defaults to none — the CLI reads its own. */
66
+ readonly env?: () => Record<string, string | undefined>;
67
+ readonly snippetMax?: number;
68
+ }
69
+
70
+ /**
71
+ * A `(args) => Promise<unknown>` runner for one CLI.
72
+ *
73
+ * Throws `"<label> <first arg> failed: <stderr snippet>"` on a non-zero exit,
74
+ * so a caller sees which subcommand failed and why.
75
+ */
76
+ export function createCliJsonRunner(
77
+ config: CliJsonRunnerConfig,
78
+ spawn: SpawnFn,
79
+ ): (args: readonly string[]) => Promise<unknown> {
80
+ const snippetMax = config.snippetMax ?? DEFAULT_STDERR_SNIPPET;
81
+ return async (args: readonly string[]): Promise<unknown> => {
82
+ const { code, stdout, stderr } = await spawn(config.argv(args), config.env?.() ?? {});
83
+ const out = stdout.trim();
84
+ if (code !== 0) {
85
+ throw new Error(
86
+ `${config.label} ${args[0] ?? ""} failed: ${stderr.trim().slice(0, snippetMax)}`,
87
+ );
88
+ }
89
+ return out === "" ? config.emptyResult : (JSON.parse(out) as unknown);
90
+ };
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Untyped-payload readers
95
+ //
96
+ // External CLI output is `unknown` at the boundary (Non-Negotiable #5), and
97
+ // these three are how every one of these connectors narrows it.
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /** A non-null, non-array object. */
101
+ export function isRecord(v: unknown): v is Record<string, unknown> {
102
+ return v !== null && typeof v === "object" && !Array.isArray(v);
103
+ }
104
+
105
+ /** `r[key]` when it is a string, else `""` — never `undefined`, never a throw. */
106
+ export function strField(r: Record<string, unknown>, key: string): string {
107
+ const v = r[key];
108
+ return typeof v === "string" ? v : "";
109
+ }
110
+
111
+ /** `parsed[key]` when `parsed` is a record and the value is an array, else `[]`. */
112
+ export function asArray(parsed: unknown, key: string): unknown[] {
113
+ if (!isRecord(parsed)) {
114
+ return [];
115
+ }
116
+ const arr = parsed[key];
117
+ return Array.isArray(arr) ? arr : [];
118
+ }
119
+
120
+ /** Case-insensitive substring match on one string field of an untyped entry. */
121
+ export function fieldMatches(entry: unknown, key: string, query: string): boolean {
122
+ return isRecord(entry) && strField(entry, key).toLowerCase().includes(query.toLowerCase());
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // gcloud
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /**
130
+ * `["--project", <id>]` when `GOOGLE_CLOUD_PROJECT` is set at spawn, else `[]`, which leaves
131
+ * gcloud on the project it is configured with.
132
+ */
133
+ export function gcloudProjectArgs(): string[] {
134
+ const project = optionalEnv("GOOGLE_CLOUD_PROJECT", "");
135
+ return project === "" ? [] : ["--project", project];
136
+ }
137
+
138
+ /**
139
+ * The environment for a `gcloud` spawn: the process environment, with
140
+ * `GOOGLE_APPLICATION_CREDENTIALS` — the key file gcloud reads natively — re-set to its trimmed
141
+ * value when it is set.
142
+ */
143
+ export function gcloudEnv(): Record<string, string | undefined> {
144
+ const env = { ...process.env } as Record<string, string | undefined>;
145
+ const credentials = process.env["GOOGLE_APPLICATION_CREDENTIALS"]?.trim();
146
+ if (credentials !== undefined && credentials !== "") {
147
+ env["GOOGLE_APPLICATION_CREDENTIALS"] = credentials;
148
+ }
149
+ return env;
150
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * collection-tool-kit — the `<prefix>_list` / `<prefix>_get` / `<prefix>_search`
3
+ * triple that most read-only REST connectors expose over a single collection.
4
+ *
5
+ * Sonar reports the tail of this shape as duplicated across readwise, mercury,
6
+ * zoom, lever and intercom, but the flagged blocks understate it: the triple is
7
+ * written out by hand in roughly thirty connectors and is the same three tool
8
+ * bodies every time —
9
+ *
10
+ * list → `jsonResult(await get(PATH))`
11
+ * get → `jsonResult(await get(ITEM_PATH(encodeURIComponent(id))))`
12
+ * search → `matchesResult(rows(await get(PATH)), filter, args)`
13
+ *
14
+ * — with only the paths, the envelope key and the descriptions differing. Those
15
+ * are the inputs here.
16
+ *
17
+ * What is NOT hidden: the descriptions. Each connector still writes its own,
18
+ * because they document that API's envelope shape and pagination and are the
19
+ * only thing the model sees when choosing a tool. A generated description would
20
+ * be a real regression in tool quality to save a line.
21
+ *
22
+ * Connectors whose tools do anything else — extra query parameters, a second
23
+ * request, a non-standard error, a POST — keep registering those directly on
24
+ * `reg`. This kit is for the plain triple, not a framework to force the rest
25
+ * into.
26
+ */
27
+
28
+ import { z } from "zod";
29
+ import { matchesResult, type SearchFilter, searchToolInputSchema } from "./mcp-search-tool.ts";
30
+ import { mcpJsonResult } from "./mcp-tool-kit.ts";
31
+ import type { ZodToolRegistrar } from "./run-read-only-mcp-connector.ts";
32
+
33
+ /** The per-connector search cap every connector in this family uses. */
34
+ export const DEFAULT_SEARCH_MAX = 100;
35
+
36
+ /** A `(path) => Promise<unknown>` client, e.g. from `createJsonGetter`. */
37
+ export type JsonGetter = (path: string) => Promise<unknown>;
38
+
39
+ /**
40
+ * Read the row array out of a list response.
41
+ *
42
+ * Most APIs wrap the rows in a named key (`{ results }`, `{ data }`,
43
+ * `{ accounts }`, `{ conversations }`); a few return a bare array, which is
44
+ * {@link identityRows}.
45
+ */
46
+ export type RowsAccessor = (root: unknown) => unknown;
47
+
48
+ /** Rows under a single envelope key, e.g. `envelopeRows("results")`. */
49
+ export function envelopeRows(key: string): RowsAccessor {
50
+ return (root: unknown): unknown => (root as Record<string, unknown> | null)?.[key];
51
+ }
52
+
53
+ /** The response IS the row array. */
54
+ export const identityRows: RowsAccessor = (root: unknown): unknown => root;
55
+
56
+ export interface CollectionListSpec {
57
+ /** Path appended to the getter's base. */
58
+ readonly path: string;
59
+ readonly description: string;
60
+ /** Tool name suffix. Defaults to `"list"`, giving `<prefix>_list`. */
61
+ readonly tool?: string;
62
+ }
63
+
64
+ export interface CollectionItemSpec {
65
+ /** Builds the path for one id. The id arrives already percent-encoded. */
66
+ readonly path: (encodedId: string) => string;
67
+ readonly description: string;
68
+ /** Tool name suffix. Defaults to `"get"`. */
69
+ readonly tool?: string;
70
+ }
71
+
72
+ export interface CollectionSearchSpec {
73
+ /** Path to search over. Defaults to the list path. */
74
+ readonly path?: string;
75
+ readonly description: string;
76
+ readonly rows: RowsAccessor;
77
+ readonly filter: SearchFilter;
78
+ /** Per-connector `limit` cap. Defaults to {@link DEFAULT_SEARCH_MAX}. */
79
+ readonly maxLimit?: number;
80
+ /** Tool name suffix. Defaults to `"search"`. */
81
+ readonly tool?: string;
82
+ }
83
+
84
+ export interface CollectionToolsConfig {
85
+ /** Tool-name prefix, e.g. `"mercury"` for `mercury_list`. */
86
+ readonly prefix: string;
87
+ readonly get: JsonGetter;
88
+ readonly list: CollectionListSpec;
89
+ readonly item?: CollectionItemSpec;
90
+ readonly search?: CollectionSearchSpec;
91
+ }
92
+
93
+ /** The names {@link registerCollectionTools} would register for `config`. */
94
+ export function collectionToolNames(config: CollectionToolsConfig): string[] {
95
+ const names = [`${config.prefix}_${config.list.tool ?? "list"}`];
96
+ if (config.item !== undefined) {
97
+ names.push(`${config.prefix}_${config.item.tool ?? "get"}`);
98
+ }
99
+ if (config.search !== undefined) {
100
+ names.push(`${config.prefix}_${config.search.tool ?? "search"}`);
101
+ }
102
+ return names;
103
+ }
104
+
105
+ /**
106
+ * Register the list/get/search triple described by `config`.
107
+ *
108
+ * `item` and `search` are optional: a few collections are list-only, and a few
109
+ * have no id to fetch by.
110
+ */
111
+ export function registerCollectionTools(
112
+ reg: ZodToolRegistrar,
113
+ config: CollectionToolsConfig,
114
+ ): void {
115
+ const { prefix, get, list, item, search } = config;
116
+
117
+ reg(`${prefix}_${list.tool ?? "list"}`, list.description, z.object({}), async () => {
118
+ return mcpJsonResult(await get(list.path));
119
+ });
120
+
121
+ if (item !== undefined) {
122
+ reg(
123
+ `${prefix}_${item.tool ?? "get"}`,
124
+ item.description,
125
+ z.object({ id: z.string().min(1) }),
126
+ async (p) => {
127
+ return mcpJsonResult(await get(item.path(encodeURIComponent(p.id))));
128
+ },
129
+ );
130
+ }
131
+
132
+ if (search !== undefined) {
133
+ reg(
134
+ `${prefix}_${search.tool ?? "search"}`,
135
+ search.description,
136
+ searchToolInputSchema(search.maxLimit ?? DEFAULT_SEARCH_MAX),
137
+ async (p) => {
138
+ const root = await get(search.path ?? list.path);
139
+ return matchesResult(search.rows(root), search.filter, p);
140
+ },
141
+ );
142
+ }
143
+ }
@@ -45,8 +45,9 @@ export function getConnectorMode(): ConnectorMode {
45
45
 
46
46
  /**
47
47
  * TEST-ONLY: clear the lock between cases. Never called from production code — the
48
- * `audit:connector-consent` gate permits `setConnectorMode` only in the two sanctioned entrypoints,
49
- * and this function exists so tests never need to reach for it.
48
+ * `audit:connector-consent` gate lets no production file here but this one name `setConnectorMode`
49
+ * (its one production caller is the gateway's `run-bundled-connector.ts`), and this function
50
+ * exists so tests never need to reach for it.
50
51
  *
51
52
  * This matters more than it looks. `bun test` runs MANY TEST FILES IN ONE PROCESS — verified: two
52
53
  * files sharing a module observed the same pid, and state set by the first was visible to the
@@ -0,0 +1,29 @@
1
+ /**
2
+ * cursor-list-tool — the input contract of a paginated `<prefix>_list` tool.
3
+ *
4
+ * Six connectors (bigeye, looker, monte-carlo, powerbi, snowflake, tableau) page through a
5
+ * collection the same way: the caller passes back an opaque `cursor` — the previous page's
6
+ * `nextCursor`, or nothing / `null` for the first page — plus a `limit`, and receives
7
+ * `{ items, nextCursor }`. Each declared that input schema separately. What the cursor encodes (an
8
+ * offset, a page number, a relay token) and the default page size stay each connector's own.
9
+ */
10
+
11
+ import { z } from "zod";
12
+ import type { ZodObjectSchema } from "./mcp-tool-kit.ts";
13
+
14
+ /** What a paginated `_list` tool is called with. */
15
+ export interface CursorListOptions {
16
+ readonly cursor?: string | null | undefined;
17
+ readonly limit?: number | undefined;
18
+ }
19
+
20
+ /**
21
+ * The paginated `_list` input schema: an optional, nullable opaque `cursor` and an optional
22
+ * integer `limit` from 1 to `maxLimit`.
23
+ */
24
+ export function cursorListInputSchema(maxLimit = 500): ZodObjectSchema<CursorListOptions> {
25
+ return z.object({
26
+ cursor: z.string().nullable().optional(),
27
+ limit: z.number().int().min(1).max(maxLimit).optional(),
28
+ });
29
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * env-json-api — the read-only JSON GET client that 31 connectors had each
3
+ * written out by hand.
4
+ *
5
+ * The shape below appeared, byte for byte, in every one of them:
6
+ *
7
+ * ```ts
8
+ * function apiToken(): string {
9
+ * const t = process.env["X_TOKEN"]?.trim();
10
+ * if (t === undefined || t === "") throw new Error("X_TOKEN is not set");
11
+ * return t;
12
+ * }
13
+ * function authHeader(): Record<string, string> {
14
+ * return { Authorization: `Bearer ${apiToken()}`, Accept: "application/json" };
15
+ * }
16
+ * async function xGet(path: string): Promise<unknown> {
17
+ * const res = await fetch(`${BASE}${path}`, { headers: authHeader() });
18
+ * const text = await res.text();
19
+ * if (!res.ok) throw new Error(`X ${String(res.status)}: ${text.slice(0, 400)}`);
20
+ * return JSON.parse(text) as unknown;
21
+ * }
22
+ * ```
23
+ *
24
+ * Only four things ever varied: the base URL, the service label in the error,
25
+ * the auth header, and (never, in practice) the 400-character snippet cap. So
26
+ * those are the inputs and the rest is here once.
27
+ *
28
+ * This is deliberately NOT `rest-tool-kit`'s `makeRestFetcher`. That one returns
29
+ * `{ ok, status, json, text }` for callers that branch on status; these 31
30
+ * connectors all throw on a non-2xx and return parsed JSON, and rewriting their
31
+ * tool bodies to the other contract would have been a behaviour change rather
32
+ * than a deduplication. Both shapes are legitimate; this module owns the
33
+ * throwing one.
34
+ *
35
+ * Environment is read at CALL time, not at module load. That is not incidental:
36
+ * the connectors behaved that way (their `apiToken()` ran per request), tests
37
+ * depend on being able to set the variable after import, and a connector must
38
+ * fail with "X_TOKEN is not set" on the tool call rather than at startup.
39
+ */
40
+
41
+ import { stripTrailingSlashes } from "./strip-trailing-slashes.ts";
42
+
43
+ /** Body-snippet length in the thrown error. The value every connector used. */
44
+ export const DEFAULT_SNIPPET_MAX = 400;
45
+
46
+ /**
47
+ * `process.env[name]`, trimmed. Throws `"<name> is not set"` when the variable
48
+ * is absent, empty, or whitespace only.
49
+ *
50
+ * Distinct from `requireProcessEnv` in `mcp-tool-kit`, which does not trim: a
51
+ * variable set to a single space passes that check and then produces an
52
+ * `Authorization: Bearer ` header and a puzzling 401. Every one of the 31
53
+ * connectors trimmed, so trimming is the behaviour being preserved here.
54
+ */
55
+ export function requiredEnv(name: string): string {
56
+ const raw = process.env[name]?.trim();
57
+ if (raw === undefined || raw === "") {
58
+ throw new Error(`${name} is not set`);
59
+ }
60
+ return raw;
61
+ }
62
+
63
+ /** `process.env[name]`, trimmed, or `fallback` when absent or empty. */
64
+ export function optionalEnv(name: string, fallback: string): string {
65
+ const raw = process.env[name]?.trim();
66
+ return raw === undefined || raw === "" ? fallback : raw;
67
+ }
68
+
69
+ /**
70
+ * A required base URL from the environment, with trailing slashes stripped so
71
+ * `${base}${path}` cannot produce a double slash.
72
+ */
73
+ export function requiredBaseUrl(name: string): string {
74
+ return stripTrailingSlashes(requiredEnv(name));
75
+ }
76
+
77
+ /** An optional base URL from the environment, trailing slashes stripped. */
78
+ export function optionalBaseUrl(name: string, fallback: string): string {
79
+ return stripTrailingSlashes(optionalEnv(name, fallback));
80
+ }
81
+
82
+ /** Builds the request headers. Called per request, so it reads env at call time. */
83
+ export type HeaderFactory = () => Record<string, string>;
84
+
85
+ /**
86
+ * `{ [header]: "<scheme> <token>", Accept: "application/json", ...extra }` where
87
+ * `token` is the trimmed value of `env`.
88
+ *
89
+ * `scheme` is the RFC 7235 auth scheme — `"Bearer"` for most, but the tree also
90
+ * contains `"Token"` (Readwise, dbt, Flagsmith) and lowercase `"token"` (Snyk),
91
+ * which are what those APIs actually accept and so are not normalised. Pass
92
+ * `scheme: ""` for the APIs that want the bare credential with no scheme
93
+ * (Bitrise, LaunchDarkly), and `header` for the ones that use their own header
94
+ * instead of `Authorization` (Codemagic, Dependency-Track, Metabase, Zotero).
95
+ */
96
+ export function envAuthHeaders(cfg: {
97
+ readonly env: string;
98
+ readonly scheme?: string;
99
+ readonly header?: string;
100
+ readonly extra?: Record<string, string>;
101
+ }): HeaderFactory {
102
+ const header = cfg.header ?? "Authorization";
103
+ const scheme = cfg.scheme ?? "Bearer";
104
+ return (): Record<string, string> => {
105
+ const token = requiredEnv(cfg.env);
106
+ return {
107
+ [header]: scheme === "" ? token : `${scheme} ${token}`,
108
+ Accept: "application/json",
109
+ ...cfg.extra,
110
+ };
111
+ };
112
+ }
113
+
114
+ /**
115
+ * The fetch the getter and the poster use. Defaults to the global `fetch`.
116
+ *
117
+ * A seam because the connectors that reach a self-hosted control plane (argocd,
118
+ * flux, mlflow) use `fetchWithTimeout` instead: a server that stops answering
119
+ * should fail the tool call, not hang it. A timeout was the whole reason they
120
+ * had carried hand-written copies of this getter.
121
+ */
122
+ export type JsonFetch = (url: string, init: RequestInit) => Promise<Response>;
123
+
124
+ export interface JsonApiConfig {
125
+ /**
126
+ * Base URL the path is appended to. A function when the base itself comes
127
+ * from the environment, so it is resolved per request rather than at import.
128
+ */
129
+ readonly base: string | (() => string);
130
+ /** Service name that prefixes a failure, e.g. `"Stripe 404: ..."`. */
131
+ readonly label: string;
132
+ readonly headers: HeaderFactory;
133
+ /** Body-snippet length in the error. Defaults to {@link DEFAULT_SNIPPET_MAX}. */
134
+ readonly snippetMax?: number;
135
+ /** Overrides the global `fetch` — see {@link JsonFetch}. */
136
+ readonly fetch?: JsonFetch;
137
+ }
138
+
139
+ /**
140
+ * One request against `config`, shared by the getter and the poster: resolve the
141
+ * base, send what `init` builds from the request headers, and return the body
142
+ * text — throwing `"<errorPrefix> <status>: <body snippet>"` on any non-2xx.
143
+ *
144
+ * The base is resolved BEFORE the headers are built, so a connector missing both
145
+ * its URL and its credential names the URL first, as every hand-written copy did.
146
+ */
147
+ function sendFor(
148
+ config: JsonApiConfig,
149
+ ): (
150
+ path: string,
151
+ init: (headers: Record<string, string>) => RequestInit,
152
+ errorPrefix: string,
153
+ ) => Promise<string> {
154
+ const { base, headers } = config;
155
+ const snippetMax = config.snippetMax ?? DEFAULT_SNIPPET_MAX;
156
+ const doFetch = config.fetch ?? ((url, init) => fetch(url, init));
157
+ return async (path, init, errorPrefix) => {
158
+ const root = typeof base === "string" ? base : base();
159
+ const res = await doFetch(`${root}${path}`, init(headers()));
160
+ const text = await res.text();
161
+ if (!res.ok) {
162
+ throw new Error(`${errorPrefix} ${String(res.status)}: ${text.slice(0, snippetMax)}`);
163
+ }
164
+ return text;
165
+ };
166
+ }
167
+
168
+ /**
169
+ * A `(path) => Promise<unknown>` GET client for `config`. Resolves to the parsed
170
+ * JSON body, and throws `"<label> <status>: <body snippet>"` on any non-2xx.
171
+ *
172
+ * `path` is appended to the base as given — callers build it, and must
173
+ * `encodeURIComponent` any user-supplied segment, exactly as before.
174
+ */
175
+ export function createJsonGetter(config: JsonApiConfig): (path: string) => Promise<unknown> {
176
+ const { label } = config;
177
+ const send = sendFor(config);
178
+ return async (path: string): Promise<unknown> =>
179
+ JSON.parse(await send(path, (headers) => ({ headers }), label)) as unknown;
180
+ }
181
+
182
+ /**
183
+ * The mutating twin of {@link createJsonGetter}: a `(path, body) => Promise<unknown>`
184
+ * POST client that sends `body` as JSON (adding `Content-Type: application/json` to
185
+ * the request headers) and resolves to the parsed response — `{}` when the response
186
+ * body is empty, as a mutation's often is.
187
+ *
188
+ * Throws `"<label> <path> <status>: <body snippet>"` on any non-2xx: unlike the
189
+ * getter's, the message names the path, so a failed mutation says which endpoint
190
+ * refused it. The argocd and mlflow write tools each carried a copy of this.
191
+ */
192
+ export function createJsonPoster(
193
+ config: JsonApiConfig,
194
+ ): (path: string, body: unknown) => Promise<unknown> {
195
+ const { label } = config;
196
+ const send = sendFor(config);
197
+ return async (path: string, body: unknown): Promise<unknown> => {
198
+ const text = await send(
199
+ path,
200
+ (headers) => ({
201
+ method: "POST",
202
+ headers: { ...headers, "Content-Type": "application/json" },
203
+ body: JSON.stringify(body),
204
+ }),
205
+ `${label} ${path}`,
206
+ );
207
+ return text === "" ? {} : (JSON.parse(text) as unknown);
208
+ };
209
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * fetch-json-text — send a JSON API request and hand back the raw response text.
3
+ *
4
+ * `atlassian-json-fetch.ts` (jira, confluence) and notion built this request the same way around
5
+ * different credentials: their own headers first, `Content-Type: application/json` only when the
6
+ * request has a body, and any header the caller passes in `init` overriding all of them. A non-2xx
7
+ * is reported rather than thrown, because these APIs put the useful diagnostic in the error body.
8
+ */
9
+ export async function fetchJsonText(
10
+ url: string,
11
+ baseHeaders: Readonly<Record<string, string>>,
12
+ init?: RequestInit,
13
+ ): Promise<{ ok: boolean; status: number; text: string }> {
14
+ const headers: Record<string, string> = { ...baseHeaders };
15
+ if (init?.body !== undefined) {
16
+ headers["Content-Type"] = "application/json";
17
+ }
18
+ const res = await fetch(url, {
19
+ ...init,
20
+ headers: {
21
+ ...headers,
22
+ ...(init?.headers as Record<string, string> | undefined),
23
+ },
24
+ });
25
+ const text = await res.text();
26
+ return { ok: res.ok, status: res.status, text };
27
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * github-rest — the GitHub REST client the `github` and `github-actions`
3
+ * connectors share.
4
+ *
5
+ * Both talk to the same API with the same token and had each written out its
6
+ * base URL, its default headers, the fetch built from them and the
7
+ * `/repos/<owner>/<repo>` path. The pinned `X-GitHub-Api-Version` lived in three
8
+ * places, so a bump had to find all three.
9
+ */
10
+
11
+ import { makeRestFetcher, type RestFetchResult } from "./rest-tool-kit.ts";
12
+
13
+ export const GH_API = "https://api.github.com";
14
+
15
+ /** The media type GitHub documents for its REST API. */
16
+ export const GH_ACCEPT = "application/vnd.github+json";
17
+
18
+ /** The REST API version both connectors are written against. */
19
+ export const GH_API_VERSION = "2022-11-28";
20
+
21
+ /** Sent with every request, beside the Bearer token. */
22
+ export const GH_HEADERS: Record<string, string> = {
23
+ Accept: GH_ACCEPT,
24
+ "X-GitHub-Api-Version": GH_API_VERSION,
25
+ };
26
+
27
+ /** A Bearer-authenticated JSON request against {@link GH_API} (a relative path or a same-origin URL). */
28
+ export function ghFetch(token: string, path: string, init?: RequestInit): Promise<RestFetchResult> {
29
+ return makeRestFetcher({ apiBase: GH_API, token, defaultHeaders: GH_HEADERS })(path, init);
30
+ }
31
+
32
+ /** `/repos/<owner>/<repo>`, both segments URI-encoded. */
33
+ export function ghRepoPath(owner: string, repo: string): string {
34
+ return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
35
+ }
36
+
37
+ /** The paging arguments every GitHub list tool accepts. */
38
+ export interface GhPaging {
39
+ readonly perPage?: number | undefined;
40
+ readonly page?: number | undefined;
41
+ }
42
+
43
+ /**
44
+ * GitHub's paging parameters, in the order every list tool sends them: `per_page` (30 when not
45
+ * given — GitHub's own default), then `page` only when one was given.
46
+ */
47
+ export function setGhPaging(params: URLSearchParams, paging: GhPaging): void {
48
+ params.set("per_page", String(paging.perPage ?? 30));
49
+ if (paging.page !== undefined) {
50
+ params.set("page", String(paging.page));
51
+ }
52
+ }
53
+
54
+ /**
55
+ * `path` plus the query string `setQuery` builds, as the relative path {@link ghFetch} takes. The
56
+ * parameters keep the order `setQuery` sets them in.
57
+ */
58
+ export function ghQueryPath(path: string, setQuery: (params: URLSearchParams) => void): string {
59
+ const u = new URL(`${GH_API}${path}`);
60
+ setQuery(u.searchParams);
61
+ return `${u.pathname}${u.search}`;
62
+ }