@nimbus-dev/connectors 0.2.1 → 0.2.3

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 (220) hide show
  1. package/README.md +19 -7
  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 +26 -43
  12. package/connectors/aws/src/server.ts +2 -183
  13. package/connectors/aws/src/tools.ts +199 -0
  14. package/connectors/azure/src/server.ts +2 -131
  15. package/connectors/azure/src/tools.ts +134 -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 +19 -35
  29. package/connectors/cloudwatch/src/tools.ts +20 -38
  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 +153 -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 +160 -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 +21 -40
  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 +601 -34
  200. package/shared/cli-json-kit.ts +191 -0
  201. package/shared/collection-tool-kit.ts +143 -0
  202. package/shared/connector-mode.ts +3 -2
  203. package/shared/consent-kit.ts +81 -26
  204. package/shared/cursor-list-tool.ts +29 -0
  205. package/shared/env-json-api.ts +209 -0
  206. package/shared/fetch-json-text.ts +27 -0
  207. package/shared/github-rest.ts +62 -0
  208. package/shared/graphql-json.ts +60 -0
  209. package/shared/imap-mail-core.ts +91 -0
  210. package/shared/imap-tool-kit.ts +57 -23
  211. package/shared/imapflow-adapter.ts +417 -0
  212. package/shared/nimbus-spawn.ts +84 -27
  213. package/shared/rest-tool-kit.ts +77 -7
  214. package/shared/run-cli-json.ts +38 -16
  215. package/shared/safe-cli-arg.ts +139 -17
  216. package/shared/search-filter.ts +58 -18
  217. package/shared/walk-files.ts +118 -0
  218. package/shared/windows-batch-args.ts +273 -0
  219. package/shared/write-budget.ts +100 -0
  220. package/standalone/src/launcher.ts +19 -7
@@ -0,0 +1,191 @@
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. Every
18
+ * caller-supplied value that reaches a CLI's argv, in all eleven connectors
19
+ * that spawn one, now passes one of the argument schemas here — including in
20
+ * the connectors that spawn through `run-cli-json.ts` rather than
21
+ * {@link createCliJsonRunner}; bigquery's values never reach argv at all. And
22
+ * `scripts/cli-argument-guards.test.ts` fails a tool whose schema lets a value
23
+ * the CLI would misread through, whether or not something later refuses it.
24
+ *
25
+ * The `gcloud` connectors (bigquery, cloud-logging, gcp, vertex-ai) also share
26
+ * how they hand gcloud its project and credentials: see {@link gcloudEnv} and
27
+ * {@link gcloudProjectArgs}.
28
+ */
29
+
30
+ import { z } from "zod";
31
+ import { optionalEnv } from "./env-json-api.ts";
32
+ import {
33
+ awsCliArgProblem,
34
+ awsCliDocumentProblem,
35
+ azCliArgProblem,
36
+ cliArgProblem,
37
+ } from "./safe-cli-arg.ts";
38
+
39
+ /** Body-snippet length in the thrown error. The value every connector used. */
40
+ export const DEFAULT_STDERR_SNIPPET = 400;
41
+
42
+ /**
43
+ * A Zod string that refuses every value `problem` names a reason against, with that reason as the
44
+ * message — so a refused call says which rule it broke rather than listing all of them.
45
+ */
46
+ function cliArgSchema(problem: (value: unknown) => string | undefined): z.ZodString {
47
+ return z
48
+ .string()
49
+ .min(1)
50
+ .refine((value) => problem(value) === undefined, {
51
+ error: (issue) => problem(issue.input) ?? "is not a safe CLI argument",
52
+ });
53
+ }
54
+
55
+ /**
56
+ * A value passed to a CLI as an argument.
57
+ *
58
+ * Rejected at the SCHEMA boundary, before the value can reach argv: a leading
59
+ * `-` would be read by the CLI as a flag rather than as the name it is meant to
60
+ * be, and control characters have no legitimate place in one.
61
+ */
62
+ export const cliArg = cliArgSchema(cliArgProblem);
63
+
64
+ /**
65
+ * A value passed to the `aws` CLI as an argument: {@link cliArg}, and not a value the CLI would
66
+ * replace by reading a file or fetching a URL (`file://`, `fileb://`, `http://`, `https://`), nor
67
+ * one carrying the shorthand file-load operator `@=`.
68
+ */
69
+ export const awsCliArg = cliArgSchema(awsCliArgProblem);
70
+
71
+ /**
72
+ * A DOCUMENT passed to the `aws` CLI as one argument — a CloudFormation template body. Unlike
73
+ * {@link awsCliArg} it may span lines and run past 1024 characters; it still may not start with `-`
74
+ * or with a prefix that makes the CLI read it from somewhere else.
75
+ */
76
+ export const awsCliDocument = cliArgSchema(awsCliDocumentProblem);
77
+
78
+ /**
79
+ * A value passed to the `az` CLI as an argument: {@link cliArg}, and not one `az` would replace by
80
+ * a file's contents — a leading `@`, or `=@` anywhere.
81
+ */
82
+ export const azCliArg = cliArgSchema(azCliArgProblem);
83
+
84
+ /** The spawn seam. Production passes `nimbusSpawn`; tests pass a fake. */
85
+ export type SpawnFn = (
86
+ command: readonly string[],
87
+ env: Record<string, string | undefined>,
88
+ ) => Promise<{ code: number; stdout: string; stderr: string }>;
89
+
90
+ export interface CliJsonRunnerConfig {
91
+ /**
92
+ * Builds the full argv for one call. Given the connector's own arguments, it
93
+ * returns the complete command — binary, subcommand, arguments and whatever
94
+ * output flag that CLI needs (`--output json` for aws, `--format json` for
95
+ * gcloud).
96
+ */
97
+ readonly argv: (args: readonly string[]) => readonly string[];
98
+ /** Prefix for the thrown error, e.g. `"aws athena"`. */
99
+ readonly label: string;
100
+ /**
101
+ * What empty stdout means. The `aws` connectors treat it as `{}` and the
102
+ * `gcloud` ones as `[]`, because that is the shape each CLI omits when there
103
+ * is nothing to report.
104
+ */
105
+ readonly emptyResult: unknown;
106
+ /** Extra environment for the spawn. Defaults to none — the CLI reads its own. */
107
+ readonly env?: () => Record<string, string | undefined>;
108
+ readonly snippetMax?: number;
109
+ }
110
+
111
+ /**
112
+ * A `(args) => Promise<unknown>` runner for one CLI.
113
+ *
114
+ * Throws `"<label> <first arg> failed: <stderr snippet>"` on a non-zero exit,
115
+ * so a caller sees which subcommand failed and why.
116
+ */
117
+ export function createCliJsonRunner(
118
+ config: CliJsonRunnerConfig,
119
+ spawn: SpawnFn,
120
+ ): (args: readonly string[]) => Promise<unknown> {
121
+ const snippetMax = config.snippetMax ?? DEFAULT_STDERR_SNIPPET;
122
+ return async (args: readonly string[]): Promise<unknown> => {
123
+ const { code, stdout, stderr } = await spawn(config.argv(args), config.env?.() ?? {});
124
+ const out = stdout.trim();
125
+ if (code !== 0) {
126
+ throw new Error(
127
+ `${config.label} ${args[0] ?? ""} failed: ${stderr.trim().slice(0, snippetMax)}`,
128
+ );
129
+ }
130
+ return out === "" ? config.emptyResult : (JSON.parse(out) as unknown);
131
+ };
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Untyped-payload readers
136
+ //
137
+ // External CLI output is `unknown` at the boundary (Non-Negotiable #5), and
138
+ // these three are how every one of these connectors narrows it.
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /** A non-null, non-array object. */
142
+ export function isRecord(v: unknown): v is Record<string, unknown> {
143
+ return v !== null && typeof v === "object" && !Array.isArray(v);
144
+ }
145
+
146
+ /** `r[key]` when it is a string, else `""` — never `undefined`, never a throw. */
147
+ export function strField(r: Record<string, unknown>, key: string): string {
148
+ const v = r[key];
149
+ return typeof v === "string" ? v : "";
150
+ }
151
+
152
+ /** `parsed[key]` when `parsed` is a record and the value is an array, else `[]`. */
153
+ export function asArray(parsed: unknown, key: string): unknown[] {
154
+ if (!isRecord(parsed)) {
155
+ return [];
156
+ }
157
+ const arr = parsed[key];
158
+ return Array.isArray(arr) ? arr : [];
159
+ }
160
+
161
+ /** Case-insensitive substring match on one string field of an untyped entry. */
162
+ export function fieldMatches(entry: unknown, key: string, query: string): boolean {
163
+ return isRecord(entry) && strField(entry, key).toLowerCase().includes(query.toLowerCase());
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // gcloud
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /**
171
+ * `["--project", <id>]` when `GOOGLE_CLOUD_PROJECT` is set at spawn, else `[]`, which leaves
172
+ * gcloud on the project it is configured with.
173
+ */
174
+ export function gcloudProjectArgs(): string[] {
175
+ const project = optionalEnv("GOOGLE_CLOUD_PROJECT", "");
176
+ return project === "" ? [] : ["--project", project];
177
+ }
178
+
179
+ /**
180
+ * The environment for a `gcloud` spawn: the process environment, with
181
+ * `GOOGLE_APPLICATION_CREDENTIALS` — the key file gcloud reads natively — re-set to its trimmed
182
+ * value when it is set.
183
+ */
184
+ export function gcloudEnv(): Record<string, string | undefined> {
185
+ const env = { ...process.env } as Record<string, string | undefined>;
186
+ const credentials = process.env["GOOGLE_APPLICATION_CREDENTIALS"]?.trim();
187
+ if (credentials !== undefined && credentials !== "") {
188
+ env["GOOGLE_APPLICATION_CREDENTIALS"] = credentials;
189
+ }
190
+ return env;
191
+ }
@@ -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
@@ -2,11 +2,9 @@ import type { McpListResult, ZodObjectSchema } from "@nimbus-dev/sdk/connector-k
2
2
 
3
3
  import { type AuditOutcome, appendAuditEntry } from "./audit-chain.ts";
4
4
  import { getConnectorMode } from "./connector-mode.ts";
5
+ import { createWriteBudget, parseWriteBudget, WRITE_BUDGET_ENV } from "./write-budget.ts";
5
6
  import { parseWriteScope, scopeAllows } from "./write-scope.ts";
6
7
 
7
- /** Mutations allowed per process lifetime when NIMBUS_MCP_WRITE_BUDGET is unset. */
8
- const DEFAULT_BUDGET = 10;
9
-
10
8
  /** The subset of the SDK's `RegisteredTool` this kit needs. */
11
9
  export type ToolHandle = { disable(): void };
12
10
 
@@ -84,6 +82,11 @@ function refused(why: string): McpListResult {
84
82
  return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: why }) }] };
85
83
  }
86
84
 
85
+ /** The message of whatever was thrown, which need not be an `Error`. */
86
+ function messageOf(e: unknown): string {
87
+ return e instanceof Error ? e.message : String(e);
88
+ }
89
+
87
90
  /**
88
91
  * Ask the human, through the client, and report whether they said yes.
89
92
  *
@@ -172,19 +175,25 @@ export function createWriteToolRegistrar(
172
175
 
173
176
  const scope = parseWriteScope(process.env[cfg.scopeEnv], cfg.scopeKinds);
174
177
  const auditLog = process.env["NIMBUS_MCP_AUDIT_LOG"];
175
- let remaining = Number(process.env["NIMBUS_MCP_WRITE_BUDGET"] ?? DEFAULT_BUDGET);
178
+ // Throws on a value that is not plain digits, an empty one included, as the scope parse above
179
+ // does on a malformed term: read with `Number()`, `abc` became NaN, which `remaining <= 0` never
180
+ // refused.
181
+ const budget = createWriteBudget(parseWriteBudget(process.env[WRITE_BUDGET_ENV]));
176
182
 
177
183
  async function record(
178
184
  tool: string,
179
185
  outcome: AuditOutcome,
180
186
  detail: Record<string, unknown>,
181
187
  ): Promise<void> {
182
- // Client-visible channel: any MCP client can display or persist this.
188
+ // Meant as a client-visible channel, but it reaches no client today: the SDK sends
189
+ // `notifications/message` only for a server that declared the MCP `logging` capability, and
190
+ // no connector declares it. The durable log below is the only record.
183
191
  await server.sendLoggingMessage({
184
192
  level: outcome === "executed" ? "info" : "warning",
185
193
  data: { connector: cfg.connector, tool, outcome },
186
194
  });
187
- // Durable channel: only when the operator configured a path.
195
+ // Durable channel: only when the operator configured a path. An append that fails rejects, and
196
+ // every record before the mutation is awaited, so a write whose entry did not land never runs.
188
197
  if (auditLog !== undefined && auditLog !== "") {
189
198
  await appendAuditEntry(auditLog, {
190
199
  ts: new Date().toISOString(),
@@ -196,6 +205,38 @@ export function createWriteToolRegistrar(
196
205
  }
197
206
  }
198
207
 
208
+ /**
209
+ * Take one unit of budget if any is left. Spending the last unit disables every write tool and
210
+ * tells the client.
211
+ *
212
+ * Nothing may be awaited between the check and the decrement, which is why both are the one
213
+ * synchronous call `budget.take()`. Calls run concurrently (the SDK starts each request's
214
+ * handler as it arrives), and with an await between the two every call in flight passed the
215
+ * check before any of them spent: three approved calls all mutated under a budget of one.
216
+ */
217
+ function spendOne(): boolean {
218
+ if (!budget.take()) return false;
219
+ if (!budget.hasLeft()) {
220
+ for (const h of handles) h.disable();
221
+ server.sendToolListChanged();
222
+ }
223
+ return true;
224
+ }
225
+
226
+ /**
227
+ * The refusal for a spent budget, worded the same for the client at both places it is checked.
228
+ * The audit reason tells them apart: `budget exhausted after approval` follows an `accepted`
229
+ * entry, because the budget ran out while that write's prompt was open.
230
+ */
231
+ async function budgetExhausted(
232
+ tool: string,
233
+ target: { kind: string; value: string },
234
+ reason: "budget exhausted" | "budget exhausted after approval",
235
+ ): Promise<McpListResult> {
236
+ await record(tool, "refused", { reason, target });
237
+ return refused("write budget exhausted for this session");
238
+ }
239
+
199
240
  /**
200
241
  * Decide the write surface, once, at the first moment client capabilities are knowable.
201
242
  *
@@ -269,11 +310,9 @@ export function createWriteToolRegistrar(
269
310
  return refused(`out of scope: ${target.kind}:${target.value} is not in ${cfg.scopeEnv}`);
270
311
  }
271
312
 
272
- // 2. BUDGET — same reasoning: a refusal that consent cannot lift comes before consent.
273
- if (remaining <= 0) {
274
- await record(name, "refused", { reason: "budget exhausted", target });
275
- return refused("write budget exhausted for this session");
276
- }
313
+ // 2. BUDGET — same reasoning: a refusal that consent cannot lift comes before consent. Not
314
+ // the last word on the budget, though: see step 4.
315
+ if (!budget.hasLeft()) return budgetExhausted(name, target, "budget exhausted");
277
316
 
278
317
  // 3. CONSENT.
279
318
  await record(name, "requested", { target, params: args });
@@ -283,7 +322,13 @@ export function createWriteToolRegistrar(
283
322
  }
284
323
  await record(name, "accepted", { target });
285
324
 
286
- // 4. PRE-STATE — after approval, before the mutation, so an unrecoverable action leaves a
325
+ // 4. SPEND — the budget checked AGAIN, and decremented in the same synchronous step: every
326
+ // call in flight passed step 2 before any of them got this far, so step 2 alone cannot
327
+ // hold the cap (see `spendOne`). Spent BEFORE the mutation so a throwing one still
328
+ // consumes budget — otherwise a failing destructive tool could be retried without limit.
329
+ if (!spendOne()) return budgetExhausted(name, target, "budget exhausted after approval");
330
+
331
+ // 5. PRE-STATE — after approval, before the mutation, so an unrecoverable action leaves a
287
332
  // record of what it destroyed. Capture failure is NOT fatal: refusing here would turn a
288
333
  // transient read error into a blocked action the owner already approved.
289
334
  let preState: Record<string, unknown> = {};
@@ -291,29 +336,39 @@ export function createWriteToolRegistrar(
291
336
  try {
292
337
  preState = await toolCfg.capturePreState(args);
293
338
  } catch (e) {
294
- preState = { captureFailed: e instanceof Error ? e.message : String(e) };
339
+ preState = { captureFailed: messageOf(e) };
295
340
  }
296
341
  }
297
342
 
298
- // 5. MUTATE. Decrement BEFORE the call so a throwing mutation still consumes budget —
299
- // otherwise a failing destructive tool could be retried without limit.
300
- remaining -= 1;
301
- if (remaining <= 0) {
302
- for (const h of handles) h.disable();
303
- server.sendToolListChanged();
343
+ // 6. MUTATE.
344
+ let result: McpListResult;
345
+ try {
346
+ result = await handler(args);
347
+ } catch (e) {
348
+ // A tool that threw may still have changed something first. If recording the failure fails
349
+ // too, the tool's own error still leads what the call reports: an audit error alone reads
350
+ // as a write that never started, and so as one that is safe to try again.
351
+ try {
352
+ await record(name, "failed", { target, preState, error: messageOf(e) });
353
+ } catch (error_) {
354
+ throw new Error(
355
+ `${name} failed: ${messageOf(e)}. Recording that failure also failed: ${messageOf(error_)}`,
356
+ { cause: e },
357
+ );
358
+ }
359
+ throw e;
304
360
  }
361
+ // Outside the `try`: the mutation has happened, so a failure to RECORD it must not be
362
+ // recorded as the mutation failing, which it was when both shared one `catch`. The call still
363
+ // rejects, saying the write ran, so it is not taken for one that is safe to try again.
305
364
  try {
306
- const result = await handler(args);
307
365
  await record(name, "executed", { target, preState });
308
- return result;
309
366
  } catch (e) {
310
- await record(name, "failed", {
311
- target,
312
- preState,
313
- error: e instanceof Error ? e.message : String(e),
367
+ throw new Error(`${name} ran, but recording that it ran failed: ${messageOf(e)}`, {
368
+ cause: e,
314
369
  });
315
- throw e;
316
370
  }
371
+ return result;
317
372
  };
318
373
 
319
374
  pending.push(() => {
@@ -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
+ }