@nimbus-dev/connectors 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/connectors/apple/src/server.ts +5 -1
  2. package/connectors/aws/src/server.ts +5 -1
  3. package/connectors/azure/src/server.ts +5 -1
  4. package/connectors/bitbucket/src/server.ts +5 -1
  5. package/connectors/circleci/src/server.ts +5 -1
  6. package/connectors/confluence/src/server.ts +5 -1
  7. package/connectors/fastmail/src/server.ts +5 -1
  8. package/connectors/gcp/src/server.ts +5 -1
  9. package/connectors/github/src/server.ts +2 -0
  10. package/connectors/github-actions/src/server.ts +5 -1
  11. package/connectors/gitlab/src/server.ts +5 -1
  12. package/connectors/gmail/src/server.ts +5 -1
  13. package/connectors/google-drive/src/server.ts +5 -1
  14. package/connectors/iac/src/server.ts +5 -1
  15. package/connectors/iac/src/tools.ts +77 -20
  16. package/connectors/imap/src/server.ts +5 -1
  17. package/connectors/jenkins/src/server.ts +5 -1
  18. package/connectors/jira/src/server.ts +5 -1
  19. package/connectors/kubernetes/src/server.ts +5 -1
  20. package/connectors/linear/src/server.ts +5 -1
  21. package/connectors/notion/src/server.ts +5 -1
  22. package/connectors/obsidian/src/server.ts +5 -1
  23. package/connectors/onedrive/src/server.ts +5 -1
  24. package/connectors/outlook/src/server.ts +5 -1
  25. package/connectors/pagerduty/src/server.ts +5 -1
  26. package/connectors/protonmail/src/server.ts +5 -1
  27. package/connectors/readwise/nimbus.extension.json +9 -1
  28. package/connectors/readwise/src/book-mapping.ts +109 -0
  29. package/connectors/readwise/src/extension.ts +11 -0
  30. package/connectors/readwise/src/highlight-mapping.ts +88 -0
  31. package/connectors/readwise/src/readwise-base.ts +6 -0
  32. package/connectors/readwise/src/record-fields.ts +72 -0
  33. package/connectors/readwise/src/sync-tool.ts +56 -0
  34. package/connectors/readwise/src/sync.ts +348 -0
  35. package/connectors/readwise/src/tools.ts +2 -2
  36. package/connectors/slack/src/server.ts +5 -1
  37. package/connectors/teams/src/server.ts +5 -1
  38. package/package.json +2 -2
  39. package/shared/cli-json-kit.ts +1 -13
  40. package/shared/consent-kit.ts +39 -17
  41. package/shared/nimbus-spawn.ts +31 -1
  42. package/shared/run-read-only-mcp-connector.ts +7 -1
  43. package/shared/safe-cli-arg.ts +0 -20
  44. package/shared/windows-batch-args.ts +23 -9
@@ -6,15 +6,15 @@ import {
6
6
  } from "../../../shared/collection-tool-kit.ts";
7
7
  import { createJsonGetter, envAuthHeaders } from "../../../shared/env-json-api.ts";
8
8
  import type { ZodToolRegistrar } from "../../../shared/run-read-only-mcp-connector.ts";
9
+ import { READWISE_BASE } from "./readwise-base.ts";
9
10
  import { filterReadwiseBooks, filterReadwiseHighlights } from "./search-filter.ts";
10
11
 
11
- const BASE = "https://readwise.io";
12
12
  const HIGHLIGHTS = "/api/v2/highlights/?page_size=1000";
13
13
  const BOOKS = "/api/v2/books/?page_size=1000";
14
14
 
15
15
  /** Readwise authenticates with `Authorization: Token <key>`, not Bearer. */
16
16
  const readwiseGet = createJsonGetter({
17
- base: BASE,
17
+ base: READWISE_BASE,
18
18
  label: "Readwise",
19
19
  headers: envAuthHeaders({ env: "READWISE_TOKEN", scheme: "Token" }),
20
20
  });
@@ -1,8 +1,12 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { CONSENT_SERVER_CAPABILITIES } from "../../../shared/consent-kit.ts";
3
4
  import { registerSlackTools } from "./tools.ts";
4
5
 
5
- const server = new McpServer({ name: "nimbus-slack", version: "0.1.0" });
6
+ const server = new McpServer(
7
+ { name: "nimbus-slack", version: "0.1.0" },
8
+ { capabilities: CONSENT_SERVER_CAPABILITIES },
9
+ );
6
10
 
7
11
  registerSlackTools(server);
8
12
 
@@ -1,8 +1,12 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { CONSENT_SERVER_CAPABILITIES } from "../../../shared/consent-kit.ts";
3
4
  import { registerTeamsTools } from "./tools.ts";
4
5
 
5
- const server = new McpServer({ name: "nimbus-teams", version: "0.1.0" });
6
+ const server = new McpServer(
7
+ { name: "nimbus-teams", version: "0.1.0" },
8
+ { capabilities: CONSENT_SERVER_CAPABILITIES },
9
+ );
6
10
 
7
11
  registerTeamsTools(server);
8
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nimbus-dev/connectors",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -26,7 +26,7 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "@modelcontextprotocol/sdk": "1.32.0",
29
- "@nimbus-dev/sdk": "^2.0.0",
29
+ "@nimbus-dev/sdk": "^2.1.0",
30
30
  "@types/nodemailer": "^8.0.2",
31
31
  "zod": "^4.6.5"
32
32
  },
@@ -29,12 +29,7 @@
29
29
 
30
30
  import { z } from "zod";
31
31
  import { optionalEnv } from "./env-json-api.ts";
32
- import {
33
- awsCliArgProblem,
34
- awsCliDocumentProblem,
35
- azCliArgProblem,
36
- cliArgProblem,
37
- } from "./safe-cli-arg.ts";
32
+ import { awsCliArgProblem, azCliArgProblem, cliArgProblem } from "./safe-cli-arg.ts";
38
33
 
39
34
  /** Body-snippet length in the thrown error. The value every connector used. */
40
35
  export const DEFAULT_STDERR_SNIPPET = 400;
@@ -68,13 +63,6 @@ export const cliArg = cliArgSchema(cliArgProblem);
68
63
  */
69
64
  export const awsCliArg = cliArgSchema(awsCliArgProblem);
70
65
 
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
66
  /**
79
67
  * A value passed to the `az` CLI as an argument: {@link cliArg}, and not one `az` would replace by
80
68
  * a file's contents — a leading `@`, or `=@` anywhere.
@@ -59,6 +59,19 @@ export type ConsentServer = {
59
59
  sendLoggingMessage(params: { level: "info" | "warning"; data: unknown }): Promise<void>;
60
60
  };
61
61
 
62
+ /**
63
+ * What a connector with write tools constructs its `McpServer` with:
64
+ * `new McpServer(info, { capabilities: CONSENT_SERVER_CAPABILITIES })`.
65
+ *
66
+ * The kit tells the client about every gated outcome with `notifications/message`, and the SDK
67
+ * sends that only for a server that declared the MCP `logging` capability. No connector did, so
68
+ * each notification was dropped without a word. It must be declared at construction: that is the
69
+ * only moment the SDK installs its `logging/setLevel` handler, and a server that declared the
70
+ * capability later would refuse a client setting the level. `scripts/connector-boot.test.ts`
71
+ * fails a connector whose manifest declares a write and whose booted server does not declare it.
72
+ */
73
+ export const CONSENT_SERVER_CAPABILITIES = { logging: {} };
74
+
62
75
  export type WriteToolConfig<T> = {
63
76
  /** Action type this tool performs, e.g. "repo.branch.delete". Machine-readable, not prose. */
64
77
  readonly mutates: string;
@@ -185,23 +198,32 @@ export function createWriteToolRegistrar(
185
198
  outcome: AuditOutcome,
186
199
  detail: Record<string, unknown>,
187
200
  ): Promise<void> {
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.
191
- await server.sendLoggingMessage({
192
- level: outcome === "executed" ? "info" : "warning",
193
- data: { connector: cfg.connector, tool, outcome },
194
- });
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.
197
- if (auditLog !== undefined && auditLog !== "") {
198
- await appendAuditEntry(auditLog, {
199
- ts: new Date().toISOString(),
200
- connector: cfg.connector,
201
- tool,
202
- outcome,
203
- detail,
204
- });
201
+ try {
202
+ // Durable channel first: only when the operator configured a path. An append that fails
203
+ // rejects, and every record before the mutation is awaited, so a write whose entry did not
204
+ // land never runs.
205
+ if (auditLog !== undefined && auditLog !== "") {
206
+ await appendAuditEntry(auditLog, {
207
+ ts: new Date().toISOString(),
208
+ connector: cfg.connector,
209
+ tool,
210
+ outcome,
211
+ detail,
212
+ });
213
+ }
214
+ } finally {
215
+ // Then the client-visible channel, told of every record attempted, one whose append failed
216
+ // included. It reaches the client because the connector declared the MCP `logging`
217
+ // capability (CONSENT_SERVER_CAPABILITIES), and a client may filter it by level. Best
218
+ // effort, and after the durable log: a client that has gone away mid-write must not keep the
219
+ // `executed` or `failed` record of a write that ran out of the log, and since the capability
220
+ // made this channel live, a dead pipe is where a send now fails.
221
+ await server
222
+ .sendLoggingMessage({
223
+ level: outcome === "executed" ? "info" : "warning",
224
+ data: { connector: cfg.connector, tool, outcome },
225
+ })
226
+ .catch(() => undefined);
205
227
  }
206
228
  }
207
229
 
@@ -16,6 +16,36 @@ import { batchArgumentRefusal } from "./windows-batch-args.ts";
16
16
 
17
17
  export type SpawnResult = { code: number; stdout: string; stderr: string };
18
18
 
19
+ /**
20
+ * The child's environment: `base` with `overrides` laid over it.
21
+ *
22
+ * On Windows a variable's name is case-insensitive, so an override replaces the base's variable
23
+ * whatever its spelling, instead of sitting beside it as a second one that the child may or may
24
+ * not see in its place. And the search path is spelled `PATH`, the only spelling Bun resolves a
25
+ * bare command through. A process started from PowerShell, cmd or Explorer has `Path`, and with it
26
+ * a bare `az` or `gcloud`, which are the batch files `az.cmd` and `gcloud.cmd` there, did not start
27
+ * at all: Bun 1.3.14 answered ENOENT, while an `.exe` such as `aws.exe` still started.
28
+ *
29
+ * Elsewhere names are case-sensitive, and the two are merged as written.
30
+ */
31
+ export function childEnvironment(
32
+ base: Record<string, string | undefined>,
33
+ overrides: Record<string, string | undefined>,
34
+ platform: NodeJS.Platform = process.platform,
35
+ ): Record<string, string | undefined> {
36
+ if (platform !== "win32") {
37
+ return { ...base, ...overrides };
38
+ }
39
+ const byName = new Map<string, readonly [string, string | undefined]>();
40
+ for (const source of [base, overrides]) {
41
+ for (const [key, value] of Object.entries(source)) {
42
+ const name = key.toUpperCase();
43
+ byName.set(name, [name === "PATH" ? "PATH" : key, value]);
44
+ }
45
+ }
46
+ return Object.fromEntries(byName.values());
47
+ }
48
+
19
49
  /**
20
50
  * The child's environment for `env`, and why `command` must not be spawned with it, if it must
21
51
  * not. Nothing is spawned for a refused command: it resolves as a failure whose stderr says so.
@@ -24,7 +54,7 @@ function prepareSpawn(
24
54
  command: readonly string[],
25
55
  env: Record<string, string | undefined>,
26
56
  ): { childEnv: Record<string, string | undefined>; refusal: SpawnResult | undefined } {
27
- const childEnv = { ...process.env, ...env };
57
+ const childEnv = childEnvironment(process.env, env);
28
58
  const refusal = batchArgumentRefusal(command, childEnv);
29
59
  return {
30
60
  childEnv,
@@ -1,5 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { CONSENT_SERVER_CAPABILITIES } from "./consent-kit.ts";
3
4
  import {
4
5
  createRegisterSimpleTool,
5
6
  createZodToolRegistrar,
@@ -41,7 +42,12 @@ export function buildReadOnlyMcpConnector(
41
42
  register: (reg: ZodToolRegistrar, server: unknown) => void,
42
43
  options?: BuildReadOnlyMcpConnectorOptions,
43
44
  ): unknown {
44
- const make = options?.createServer ?? ((info) => new McpServer(info));
45
+ // Every server built here declares the MCP `logging` capability, since the consent kit's
46
+ // notifications reach a client only through it. A truly read-only connector sends no log
47
+ // message, so declaring it costs that connector nothing.
48
+ const make =
49
+ options?.createServer ??
50
+ ((info) => new McpServer(info, { capabilities: CONSENT_SERVER_CAPABILITIES }));
45
51
  const mcp = make({ name: serverName, version: "0.1.0" });
46
52
  const reg = createZodToolRegistrar(createRegisterSimpleTool(mcp));
47
53
  register(reg, mcp);
@@ -124,26 +124,6 @@ export function awsCliArgProblem(value: unknown): string | undefined {
124
124
  return cliArgProblem(value) ?? awsPrefixProblem(value) ?? awsShorthandProblem(value);
125
125
  }
126
126
 
127
- /**
128
- * Why `value` must not be passed to the AWS CLI as a DOCUMENT — a CloudFormation template body —
129
- * or `undefined` when it may.
130
- *
131
- * A document legitimately spans lines and runs past the 1024 characters an argument is held to,
132
- * so of the argument rules only the two about where the CLI looks remain: a leading `-`, which the
133
- * CLI would parse as an option, and a loading prefix, which would make it read the "document" from
134
- * a file. `@=` is not among them: shorthand syntax applies to structure parameters, and a template
135
- * body is a string.
136
- */
137
- export function awsCliDocumentProblem(value: unknown): string | undefined {
138
- if (typeof value !== "string" || value.length === 0) {
139
- return "must be a non-empty string";
140
- }
141
- if (value.startsWith("-")) {
142
- return 'must not start with "-" (argv flag smuggling is not allowed)';
143
- }
144
- return awsPrefixProblem(value);
145
- }
146
-
147
127
  // ---------------------------------------------------------------------------
148
128
  // az
149
129
  // ---------------------------------------------------------------------------
@@ -28,9 +28,9 @@
28
28
  * AWS resource name, an Azure app, cluster or node pool name, or a subscription id. An Azure
29
29
  * resource group may hold `(` and `)`, but never a space, so the runtime never quotes it and
30
30
  * `az.cmd` already failed on it as above. What did work and is now refused: an Azure subscription
31
- * passed by a display name holding one of these characters and a space — its id still works — and
32
- * a CloudFormation template body, whenever the `aws` that runs is a batch file: a pip-installed v1
33
- * found on `PATH` before any `aws.exe`.
31
+ * passed by a display name holding one of these characters and a space — its id still works. A
32
+ * CloudFormation template, full of quotes, was refused too whenever the `aws` that runs is a batch
33
+ * file, until it began to reach `aws` as a file instead of an argument.
34
34
  *
35
35
  * Which file runs is decided the way the runtime decides it — see {@link mayRunAsBatchFile} — so
36
36
  * an `aws.exe` found first is never refused, whatever batch file of that name comes later.
@@ -77,12 +77,25 @@ export function cmdMetacharacterIn(value: string): string | undefined {
77
77
  return undefined;
78
78
  }
79
79
 
80
+ /**
81
+ * `name` without the dots and spaces at its end. A loop rather than `/[. ]+$/`, whose engine
82
+ * retries the run from every start position and so takes quadratic time on a long run of dots
83
+ * and spaces that something else follows.
84
+ */
85
+ function withoutTrailingDotsAndSpaces(name: string): string {
86
+ let end = name.length;
87
+ while (end > 0 && (name[end - 1] === "." || name[end - 1] === " ")) {
88
+ end -= 1;
89
+ }
90
+ return name.slice(0, end);
91
+ }
92
+
80
93
  /**
81
94
  * Whether Windows runs a file of this name through cmd.exe: a `.cmd` or a `.bat`, in any case,
82
95
  * after the trailing dots and spaces Windows drops from a file name — `az.cmd.` opens `az.cmd`.
83
96
  */
84
97
  export function isBatchFileName(name: string): boolean {
85
- const trimmed = name.replace(/[. ]+$/, "").toLowerCase();
98
+ const trimmed = withoutTrailingDotsAndSpaces(name).toLowerCase();
86
99
  return BATCH_EXTENSIONS.some((ext) => trimmed.endsWith(ext));
87
100
  }
88
101
 
@@ -183,11 +196,12 @@ function firstHolderIsBatch(bases: readonly string[], exists: (path: string) =>
183
196
  * and libuv starts only a `.com` or an `.exe`: never a batch file.
184
197
  *
185
198
  * It says yes where Bun could change without notice, and nowhere else: a PATH variable counts in
186
- * any spelling, not only `PATH` — today Bun reads no other, and a bare `az` with only `Path` set
187
- * does not start at all — and each is searched both as written and with whitespace and quotes
188
- * stripped from its entries; a name written with a `/` is looked up in its directory as one with
189
- * a `\` is; and the first place holding the name answers yes if it holds a `.cmd` or `.bat` by
190
- * that name, even beside an `.exe` Bun would try first.
199
+ * any spelling, not only `PATH` — Bun reads no other, though `nimbus-spawn.ts` respells the
200
+ * variable `PATH` before every spawn, so a bare `az` under `Path` starts too — and each is searched
201
+ * both as written and with whitespace and quotes stripped from its entries; a name written with a
202
+ * `/` is looked up in its directory as one with a `\` is; and the first place holding the name
203
+ * answers yes if it holds a `.cmd` or `.bat` by that name, even beside an `.exe` Bun would try
204
+ * first.
191
205
  */
192
206
  export function mayRunAsBatchFile(
193
207
  bin: string,