@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.
- package/README.md +19 -7
- package/connectors/airflow/src/search-filter.ts +2 -15
- package/connectors/airflow/src/server.ts +2 -87
- package/connectors/airflow/src/tools.ts +74 -0
- package/connectors/apple/src/adapters.ts +326 -0
- package/connectors/apple/src/apple-mail-core.ts +4 -8
- package/connectors/apple/src/calendar-tools.ts +28 -8
- package/connectors/apple/src/server.ts +23 -512
- package/connectors/apple/src/tools.ts +18 -44
- package/connectors/argocd/src/server.ts +25 -38
- package/connectors/athena/src/tools.ts +26 -43
- package/connectors/aws/src/server.ts +2 -183
- package/connectors/aws/src/tools.ts +199 -0
- package/connectors/azure/src/server.ts +2 -131
- package/connectors/azure/src/tools.ts +134 -0
- package/connectors/bigeye/src/search-filter.ts +4 -15
- package/connectors/bigeye/src/server.ts +38 -38
- package/connectors/bigquery/src/tools.ts +1 -26
- package/connectors/bitbucket/src/server.ts +2 -244
- package/connectors/bitbucket/src/tools.ts +241 -0
- package/connectors/bitrise/src/server.ts +2 -79
- package/connectors/bitrise/src/tools.ts +76 -0
- package/connectors/canva/src/search-filter.ts +2 -11
- package/connectors/canva/src/server.ts +2 -73
- package/connectors/canva/src/tools.ts +66 -0
- package/connectors/circleci/src/server.ts +2 -176
- package/connectors/circleci/src/tools.ts +193 -0
- package/connectors/cloud-logging/src/tools.ts +19 -35
- package/connectors/cloudwatch/src/tools.ts +20 -38
- package/connectors/codemagic/src/server.ts +2 -70
- package/connectors/codemagic/src/tools.ts +71 -0
- package/connectors/confluence/src/server.ts +2 -301
- package/connectors/confluence/src/tools.ts +320 -0
- package/connectors/dagster/src/server.ts +2 -214
- package/connectors/dagster/src/tools.ts +187 -0
- package/connectors/databricks/src/search-filter.ts +7 -7
- package/connectors/databricks/src/server.ts +2 -77
- package/connectors/databricks/src/tools.ts +66 -0
- package/connectors/datadog/src/server.ts +2 -48
- package/connectors/datadog/src/tools.ts +48 -0
- package/connectors/dataprofile/src/profile.ts +16 -46
- package/connectors/dbt/src/server.ts +2 -92
- package/connectors/dbt/src/tools.ts +80 -0
- package/connectors/dependencytrack/src/server.ts +2 -76
- package/connectors/dependencytrack/src/tools.ts +65 -0
- package/connectors/discord/src/server.ts +2 -85
- package/connectors/discord/src/tools.ts +82 -0
- package/connectors/elasticsearch/src/tools.ts +7 -37
- package/connectors/fastmail/src/jmap-client.ts +202 -0
- package/connectors/fastmail/src/jmap-core.ts +8 -21
- package/connectors/fastmail/src/server.ts +2 -187
- package/connectors/fastmail/src/tools.ts +4 -23
- package/connectors/figma/src/search-filter.ts +2 -11
- package/connectors/figma/src/server.ts +2 -113
- package/connectors/figma/src/tools.ts +99 -0
- package/connectors/firebase/src/server.ts +2 -90
- package/connectors/firebase/src/token.ts +2 -5
- package/connectors/firebase/src/tools.ts +106 -0
- package/connectors/flagsmith/src/server.ts +2 -98
- package/connectors/flagsmith/src/tools.ts +95 -0
- package/connectors/flux/src/server.ts +52 -53
- package/connectors/gcp/src/server.ts +2 -132
- package/connectors/gcp/src/tools.ts +123 -0
- package/connectors/github/src/server.ts +2 -328
- package/connectors/github/src/tools.ts +311 -0
- package/connectors/github-actions/src/server.ts +2 -205
- package/connectors/github-actions/src/tools.ts +209 -0
- package/connectors/gitlab/src/server.ts +2 -384
- package/connectors/gitlab/src/tools.ts +392 -0
- package/connectors/gmail/src/server.ts +2 -270
- package/connectors/gmail/src/tools.ts +209 -0
- package/connectors/google-drive/src/server.ts +2 -524
- package/connectors/google-drive/src/tools.ts +529 -0
- package/connectors/google-meet/src/server.ts +2 -82
- package/connectors/google-meet/src/tools.ts +87 -0
- package/connectors/google-photos/src/server.ts +2 -132
- package/connectors/google-photos/src/tools.ts +138 -0
- package/connectors/grafana/src/server.ts +2 -53
- package/connectors/grafana/src/tools.ts +53 -0
- package/connectors/great-expectations/src/gx-parse.ts +14 -45
- package/connectors/greenhouse/src/server.ts +2 -63
- package/connectors/greenhouse/src/tools.ts +68 -0
- package/connectors/hubspot/src/server.ts +2 -77
- package/connectors/hubspot/src/tools.ts +70 -0
- package/connectors/iac/src/server.ts +2 -137
- package/connectors/iac/src/tools.ts +153 -0
- package/connectors/imap/src/imap-core.ts +29 -82
- package/connectors/imap/src/server.ts +33 -220
- package/connectors/intercom/src/server.ts +2 -61
- package/connectors/intercom/src/tools.ts +58 -0
- package/connectors/jenkins/src/jenkins-api.ts +2 -6
- package/connectors/jenkins/src/server.ts +2 -215
- package/connectors/jenkins/src/tools.ts +240 -0
- package/connectors/jira/src/server.ts +2 -301
- package/connectors/jira/src/tools.ts +325 -0
- package/connectors/kubernetes/src/server.ts +2 -155
- package/connectors/kubernetes/src/tools.ts +160 -0
- package/connectors/launchdarkly/src/server.ts +2 -75
- package/connectors/launchdarkly/src/tools.ts +72 -0
- package/connectors/lever/src/search-filter.ts +4 -12
- package/connectors/lever/src/server.ts +2 -60
- package/connectors/lever/src/tools.ts +61 -0
- package/connectors/linear/src/server.ts +2 -459
- package/connectors/linear/src/tools.ts +482 -0
- package/connectors/localdb/src/sql-scan.ts +13 -41
- package/connectors/localdb/src/tools.ts +1 -1
- package/connectors/looker/src/search-filter.ts +2 -15
- package/connectors/looker/src/server.ts +35 -70
- package/connectors/mendeley/src/server.ts +2 -55
- package/connectors/mendeley/src/tools.ts +54 -0
- package/connectors/mercury/src/server.ts +2 -57
- package/connectors/mercury/src/tools.ts +50 -0
- package/connectors/metabase/src/search-filter.ts +4 -15
- package/connectors/metabase/src/server.ts +2 -79
- package/connectors/metabase/src/tools.ts +64 -0
- package/connectors/miro/src/server.ts +2 -72
- package/connectors/miro/src/tools.ts +65 -0
- package/connectors/mlflow/src/search-filter.ts +3 -7
- package/connectors/mlflow/src/server.ts +20 -40
- package/connectors/monte-carlo/src/server.ts +50 -57
- package/connectors/netlify/src/search-filter.ts +6 -14
- package/connectors/netlify/src/server.ts +2 -60
- package/connectors/netlify/src/tools.ts +53 -0
- package/connectors/newrelic/src/server.ts +2 -40
- package/connectors/newrelic/src/tools.ts +37 -0
- package/connectors/notion/src/server.ts +2 -335
- package/connectors/notion/src/tools.ts +344 -0
- package/connectors/obsidian/src/server.ts +2 -425
- package/connectors/obsidian/src/tools.ts +466 -0
- package/connectors/onedrive/src/server.ts +2 -249
- package/connectors/onedrive/src/tools.ts +253 -0
- package/connectors/outlook/src/server.ts +2 -351
- package/connectors/outlook/src/tools.ts +357 -0
- package/connectors/pagerduty/src/server.ts +2 -132
- package/connectors/pagerduty/src/tools.ts +126 -0
- package/connectors/pipedrive/src/server.ts +2 -58
- package/connectors/pipedrive/src/tools.ts +63 -0
- package/connectors/powerbi/src/search-filter.ts +2 -15
- package/connectors/powerbi/src/server.ts +43 -68
- package/connectors/prefect/src/search-filter.ts +3 -16
- package/connectors/prefect/src/server.ts +2 -90
- package/connectors/prefect/src/tools.ts +83 -0
- package/connectors/protonmail/src/mail-core.ts +16 -75
- package/connectors/protonmail/src/server.ts +34 -214
- package/connectors/raindrop/src/server.ts +2 -103
- package/connectors/raindrop/src/tools.ts +103 -0
- package/connectors/ramp/src/server.ts +2 -105
- package/connectors/ramp/src/tools.ts +98 -0
- package/connectors/readwise/src/server.ts +2 -88
- package/connectors/readwise/src/tools.ts +81 -0
- package/connectors/sagemaker/src/tools.ts +21 -40
- package/connectors/salesforce/src/search-filter.ts +4 -11
- package/connectors/salesforce/src/server.ts +2 -90
- package/connectors/salesforce/src/tools.ts +75 -0
- package/connectors/semgrep/src/search-filter.ts +3 -10
- package/connectors/semgrep/src/server.ts +2 -104
- package/connectors/semgrep/src/tools.ts +92 -0
- package/connectors/sentry/src/server.ts +2 -58
- package/connectors/sentry/src/tools.ts +53 -0
- package/connectors/slack/src/server.ts +2 -352
- package/connectors/slack/src/tools.ts +376 -0
- package/connectors/snowflake/src/search-filter.ts +4 -19
- package/connectors/snowflake/src/server.ts +6 -25
- package/connectors/snyk/src/server.ts +2 -140
- package/connectors/snyk/src/tools.ts +136 -0
- package/connectors/sonarqube/src/server.ts +2 -111
- package/connectors/sonarqube/src/tools.ts +98 -0
- package/connectors/stackoverflow/src/server.ts +2 -77
- package/connectors/stackoverflow/src/tools.ts +66 -0
- package/connectors/storybook/src/storybook-parse.ts +3 -6
- package/connectors/stripe/src/server.ts +2 -61
- package/connectors/stripe/src/tools.ts +54 -0
- package/connectors/superset/src/search-filter.ts +4 -15
- package/connectors/superset/src/server.ts +2 -115
- package/connectors/superset/src/tools.ts +98 -0
- package/connectors/tableau/src/search-filter.ts +2 -15
- package/connectors/tableau/src/server.ts +13 -65
- package/connectors/teams/src/server.ts +2 -249
- package/connectors/teams/src/tools.ts +283 -0
- package/connectors/testflight/src/server.ts +2 -70
- package/connectors/testflight/src/tools.ts +79 -0
- package/connectors/vercel/src/search-filter.ts +2 -10
- package/connectors/vercel/src/server.ts +2 -75
- package/connectors/vercel/src/tools.ts +73 -0
- package/connectors/vertex-ai/src/tools.ts +23 -33
- package/connectors/wiz/src/search-filter.ts +6 -30
- package/connectors/wiz/src/server.ts +2 -188
- package/connectors/wiz/src/tools.ts +168 -0
- package/connectors/zendesk/src/server.ts +2 -73
- package/connectors/zendesk/src/tools.ts +60 -0
- package/connectors/zoom/src/search-filter.ts +1 -7
- package/connectors/zoom/src/server.ts +2 -94
- package/connectors/zoom/src/tools.ts +93 -0
- package/connectors/zotero/src/server.ts +2 -78
- package/connectors/zotero/src/tools.ts +67 -0
- package/package.json +14 -12
- package/shared/access-token-cache.ts +123 -0
- package/shared/atlassian-json-fetch.ts +6 -16
- package/shared/audit-chain.ts +601 -34
- package/shared/cli-json-kit.ts +191 -0
- package/shared/collection-tool-kit.ts +143 -0
- package/shared/connector-mode.ts +3 -2
- package/shared/consent-kit.ts +81 -26
- package/shared/cursor-list-tool.ts +29 -0
- package/shared/env-json-api.ts +209 -0
- package/shared/fetch-json-text.ts +27 -0
- package/shared/github-rest.ts +62 -0
- package/shared/graphql-json.ts +60 -0
- package/shared/imap-mail-core.ts +91 -0
- package/shared/imap-tool-kit.ts +57 -23
- package/shared/imapflow-adapter.ts +417 -0
- package/shared/nimbus-spawn.ts +84 -27
- package/shared/rest-tool-kit.ts +77 -7
- package/shared/run-cli-json.ts +38 -16
- package/shared/safe-cli-arg.ts +139 -17
- package/shared/search-filter.ts +58 -18
- package/shared/walk-files.ts +118 -0
- package/shared/windows-batch-args.ts +273 -0
- package/shared/write-budget.ts +100 -0
- 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
|
+
}
|
package/shared/connector-mode.ts
CHANGED
|
@@ -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
|
|
49
|
-
*
|
|
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
|
package/shared/consent-kit.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
274
|
-
|
|
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.
|
|
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:
|
|
339
|
+
preState = { captureFailed: messageOf(e) };
|
|
295
340
|
}
|
|
296
341
|
}
|
|
297
342
|
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
+
}
|