@nimbus-dev/connectors 0.3.0 → 0.5.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.
@@ -43,15 +43,59 @@ const listArgs = z.object({
43
43
  calendar: z.string().min(1).optional(),
44
44
  });
45
45
 
46
+ /** A C0 control (CR, LF and TAB included), DEL, or a C1 control. */
47
+ function isControlCode(code: number): boolean {
48
+ return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
49
+ }
50
+
51
+ /**
52
+ * Whether `value` carries a control character anywhere. Event text reaches iCalendar content
53
+ * lines, where CR or LF ends a line and starts a new property; the SDK's escaping turns CRLF or LF
54
+ * into an escaped newline but leaves a bare CR. So the input is REFUSED here rather than relying
55
+ * on (or adding) an escape. Nothing is stripped.
56
+ */
57
+ export function hasControlCharacter(value: string): boolean {
58
+ for (let i = 0; i < value.length; i++) {
59
+ if (isControlCode(value.charCodeAt(i))) return true;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /** Whether `value` carries any whitespace (Unicode `\s`) or control character. */
65
+ export function hasWhitespaceOrControl(value: string): boolean {
66
+ return /\s/u.test(value) || hasControlCharacter(value);
67
+ }
68
+
69
+ export const ICLOUD_TEXT_REFUSAL = "must not contain a line break or other control character";
70
+ export const ICLOUD_TIME_REFUSAL =
71
+ "must be a UTC time in the form YYYYMMDDTHHMMSSZ; all-day and floating times are not supported";
72
+ export const ICLOUD_UID_REFUSAL =
73
+ "must be 1 to 200 characters, each a letter, a digit, '.', '_', '@' or '-'";
74
+ export const ICLOUD_ATTENDEE_REFUSAL =
75
+ "each attendee must be one email address, with no whitespace or control character";
76
+
77
+ /** `YYYYMMDDTHHMMSSZ`: a UTC date-time and nothing else (owner ruling OR-B4-6). */
78
+ const UTC_DATE_TIME = /^\d{8}T\d{6}Z$/;
79
+ const EVENT_UID = /^[A-Za-z0-9._@-]{1,200}$/;
80
+
81
+ /** A free-text event field: refused, never cleaned, when it carries a control character. */
82
+ function eventText(): z.ZodString {
83
+ return z.string().refine((s) => !hasControlCharacter(s), ICLOUD_TEXT_REFUSAL);
84
+ }
85
+
46
86
  const createArgs = z.object({
47
- calendar: z.string().min(1).optional(),
48
- summary: z.string().min(1).max(1000),
49
- start: z.string().min(1),
50
- end: z.string().min(1),
51
- description: z.string().max(10_000).optional(),
52
- location: z.string().max(500).optional(),
53
- attendees: z.array(z.email()).optional(),
54
- uid: z.string().min(1).optional(),
87
+ calendar: eventText().min(1).optional(),
88
+ summary: eventText().min(1).max(1000),
89
+ start: z.string().regex(UTC_DATE_TIME, ICLOUD_TIME_REFUSAL),
90
+ end: z.string().regex(UTC_DATE_TIME, ICLOUD_TIME_REFUSAL),
91
+ description: eventText().max(10_000).optional(),
92
+ location: eventText().max(500).optional(),
93
+ // z.email() already refuses whitespace and controls today; the explicit check keeps that a
94
+ // stated property of THIS schema, not of zod's current email pattern.
95
+ attendees: z
96
+ .array(z.email().refine((s) => !hasWhitespaceOrControl(s), ICLOUD_ATTENDEE_REFUSAL))
97
+ .optional(),
98
+ uid: z.string().regex(EVENT_UID, ICLOUD_UID_REFUSAL).optional(),
55
99
  });
56
100
 
57
101
  const deleteArgs = z.object({
@@ -199,7 +243,7 @@ export function registerAppleCalendarTools(
199
243
  recoverable: true,
200
244
  scopeTargetOf: (p) => ({ kind: "calendar", value: p.calendar ?? "default" }),
201
245
  },
202
- "Create a new event in iCloud Calendar via CalDAV PUT.",
246
+ "Create a new event in iCloud Calendar via CalDAV PUT. start and end are UTC, in the form YYYYMMDDTHHMMSSZ (all-day and floating times are refused). summary, description, location and calendar may not contain a line break or other control character; uid, when given, is 1-200 of A-Z a-z 0-9 . _ @ -.",
203
247
  createArgs,
204
248
  async (parsedData) => {
205
249
  const parsed = { success: true as const, data: parsedData };
@@ -11,6 +11,7 @@ import { parseCitationsJson } from "../../../shared/kb-markdown.ts";
11
11
  import {
12
12
  createRegisterSimpleTool,
13
13
  createZodToolRegistrar,
14
+ mcpJsonResult,
14
15
  mcpJsonResultFromTextIfOk,
15
16
  } from "../../../shared/mcp-tool-kit.ts";
16
17
  import { buildConfluenceKbPageBody } from "./kb-append.ts";
@@ -40,6 +41,67 @@ async function confFetch(
40
41
  return fetchAtlassianBasicAuthJsonText(url, email, token, init);
41
42
  }
42
43
 
44
+ /** The parts of a page `confluence_page_append` reads and writes back. */
45
+ interface PageSnapshot {
46
+ readonly type: string;
47
+ readonly title: string;
48
+ readonly version: number;
49
+ readonly html: string;
50
+ }
51
+
52
+ /** Parse `GET /content/{id}?expand=body.storage,version`; throws, quoting Confluence, on a failure. */
53
+ function readPageSnapshot(res: { ok: boolean; status: number; text: string }): PageSnapshot {
54
+ if (!res.ok) {
55
+ throw new Error(`Confluence ${String(res.status)}: ${res.text.slice(0, 400)}`);
56
+ }
57
+ const page = JSON.parse(res.text) as {
58
+ type?: string;
59
+ title?: string;
60
+ version?: { number?: number };
61
+ body?: { storage?: { value?: string } };
62
+ };
63
+ if (page.version?.number === undefined) {
64
+ throw new Error("Confluence: page has no version");
65
+ }
66
+ return {
67
+ type: page.type ?? "page",
68
+ title: page.title ?? "",
69
+ version: page.version.number,
70
+ html: page.body?.storage?.value ?? "",
71
+ };
72
+ }
73
+
74
+ /** One entry of `confluence_page_get`'s `ancestors`: what a caller needs, not the whole page. */
75
+ export interface PageAncestor {
76
+ readonly id: string | null;
77
+ readonly title: string | null;
78
+ }
79
+
80
+ /**
81
+ * Narrow the `ancestors` Confluence expands on a page to `{ id, title }` per entry, in Confluence's
82
+ * order (the space's top-level page first, the page's direct parent last). An ancestor is a whole
83
+ * content object (links, extensions, status); a caller deciding where a page sits needs only its
84
+ * id, and the title is free. An entry without a string id keeps its place with `id: null` rather
85
+ * than being dropped, so the chain is never shorter than Confluence said. A reply with no
86
+ * `ancestors` array is returned as it came: absent stays absent, never an invented `[]`.
87
+ */
88
+ export function withAncestorSummaries(page: unknown): unknown {
89
+ if (page === null || typeof page !== "object" || Array.isArray(page)) return page;
90
+ const obj = page as Record<string, unknown>;
91
+ const ancestors = obj["ancestors"];
92
+ if (!Array.isArray(ancestors)) return page;
93
+ const summaries: PageAncestor[] = ancestors.map((a: unknown) => {
94
+ const entry = a !== null && typeof a === "object" ? (a as Record<string, unknown>) : {};
95
+ const id = entry["id"];
96
+ const title = entry["title"];
97
+ return {
98
+ id: typeof id === "string" ? id : null,
99
+ title: typeof title === "string" ? title : null,
100
+ };
101
+ });
102
+ return { ...obj, ancestors: summaries };
103
+ }
104
+
43
105
  /** Tool names exposed by this connector — for contract/introspection tests. */
44
106
  export const CONFLUENCE_TOOL_NAMES = [
45
107
  "confluence_space_list",
@@ -52,6 +114,7 @@ export const CONFLUENCE_TOOL_NAMES = [
52
114
  "confluence_kb_append",
53
115
  "confluence_page_update",
54
116
  "confluence_comment_add",
117
+ "confluence_page_append",
55
118
  ] as const;
56
119
 
57
120
  export function registerConfluenceTools(
@@ -118,14 +181,23 @@ export function registerConfluenceTools(
118
181
 
119
182
  reg(
120
183
  "confluence_page_get",
121
- "Get a Confluence page with body.storage (GET /content/{id}).",
184
+ "Get a Confluence page with body.storage (GET /content/{id}). ancestors lists the pages above it as { id, title }, the top-level page first and the direct parent last.",
122
185
  confluencePageIdSchema,
123
186
  async (parsed) => {
124
187
  const id = encodeURIComponent(parsed.pageId);
125
188
  const res = await confFetch(
126
- `/content/${id}?expand=body.storage,version,history.lastUpdated,space`,
189
+ `/content/${id}?expand=body.storage,version,history.lastUpdated,space,ancestors`,
127
190
  );
128
- return mcpJsonResultFromTextIfOk("Confluence", res);
191
+ if (!res.ok) {
192
+ throw new Error(`Confluence ${String(res.status)}: ${res.text.slice(0, 400)}`);
193
+ }
194
+ let page: unknown;
195
+ try {
196
+ page = JSON.parse(res.text) as unknown;
197
+ } catch {
198
+ throw new Error("Confluence: invalid JSON response");
199
+ }
200
+ return mcpJsonResult(withAncestorSummaries(page));
129
201
  },
130
202
  );
131
203
 
@@ -317,4 +389,63 @@ export function registerConfluenceTools(
317
389
  return mcpJsonResultFromTextIfOk("Confluence", res);
318
390
  },
319
391
  );
392
+
393
+ const confluencePageAppendSchema = z.object({
394
+ pageId: z.string().min(1),
395
+ storageHtml: z.string().min(1).max(65_000),
396
+ });
397
+
398
+ registerWriteTool(
399
+ "confluence_page_append",
400
+ {
401
+ mutates: "confluence.page.append",
402
+ // Confluence keeps every version of a page, so the previous body is one click away. No
403
+ // capturePreState on purpose: it would run once, before a retry, and describe a version that
404
+ // may not be the one this call changes.
405
+ recoverable: true,
406
+ scopeTargetOf: (p) => ({ kind: "page", value: p.pageId }),
407
+ },
408
+ "Append storage-format HTML to the END of a Confluence page, keeping everything already on it (GET then PUT /content/{id}); if the page changes while this runs it re-reads and retries once, then fails without writing.",
409
+ confluencePageAppendSchema,
410
+ async (parsed) => {
411
+ const id = encodeURIComponent(parsed.pageId);
412
+ // The fetch helper returns `{ ok, status, text }` without throwing, so the retry lives here.
413
+ // Each attempt re-reads the page, so it appends to the body as it is NOW.
414
+ for (let attempt = 1; attempt <= 2; attempt++) {
415
+ const page = readPageSnapshot(
416
+ await confFetch(`/content/${id}?expand=body.storage,version`),
417
+ );
418
+ const next = page.version + 1;
419
+ const put = await confFetch(`/content/${id}`, {
420
+ method: "PUT",
421
+ body: JSON.stringify({
422
+ type: page.type,
423
+ title: page.title,
424
+ version: { number: next, message: "nimbus" },
425
+ body: {
426
+ storage: { value: page.html + parsed.storageHtml, representation: "storage" },
427
+ },
428
+ }),
429
+ });
430
+ if (put.status === 409 && attempt === 1) {
431
+ continue;
432
+ }
433
+ if (put.status === 409) {
434
+ throw new Error("Confluence: page changed concurrently; nothing appended");
435
+ }
436
+ if (!put.ok) {
437
+ throw new Error(`Confluence ${String(put.status)}: ${put.text.slice(0, 400)}`);
438
+ }
439
+ let version = next;
440
+ try {
441
+ const written = JSON.parse(put.text) as { version?: { number?: number } };
442
+ version = written.version?.number ?? next;
443
+ } catch {
444
+ // A non-JSON success body: the version we sent is the one that was written.
445
+ }
446
+ return mcpJsonResult({ ok: true, pageId: parsed.pageId, version });
447
+ }
448
+ throw new Error("Confluence: page changed concurrently; nothing appended");
449
+ },
450
+ );
320
451
  }
@@ -2,14 +2,14 @@
2
2
  "id": "com.nimbus.discord",
3
3
  "displayName": "Discord",
4
4
  "version": "0.1.0",
5
- "description": "Discord opt-in connector (guilds, channels, messages). Disabled by default. Mutations require Gateway HITL.",
5
+ "description": "Discord opt-in connector (guilds, channels, messages; posts as the bot). Disabled by default. Mutations require Gateway HITL.",
6
6
  "author": "Nimbus",
7
7
  "entrypoint": "dist/server.js",
8
8
  "runtime": "bun",
9
9
  "permissions": {
10
10
  "network": ["discord.com", "gateway.discord.gg", "cdn.discordapp.com"]
11
11
  },
12
- "hitlRequired": [],
12
+ "hitlRequired": ["write"],
13
13
  "syncInterval": 120,
14
14
  "minNimbusVersion": "0.2.0"
15
15
  }
@@ -1,8 +1,21 @@
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 { registerDiscordTools } from "./tools.ts";
4
5
 
5
- const server = new McpServer({ name: "nimbus-discord", version: "0.1.0" });
6
+ const server = new McpServer(
7
+ { name: "nimbus-discord", version: "0.1.0" },
8
+ {
9
+ capabilities: CONSENT_SERVER_CAPABILITIES,
10
+ // Machine-readable security tiering, so a client can surface it rather than relying on a
11
+ // human having read the NOTICE file.
12
+ instructions:
13
+ "Nimbus Discord connector. Standalone: the message-post tool requires MCP elicitation " +
14
+ "consent and is limited by NIMBUS_MCP_DISCORD_WRITE_SCOPE (channel:<id>); it is not " +
15
+ "registered at all if this client does not support elicitation. No sandbox, no OS keychain " +
16
+ "and no egress ledger outside the Nimbus gateway. See NOTICE.",
17
+ },
18
+ );
6
19
 
7
20
  registerDiscordTools(server);
8
21
 
@@ -1,5 +1,11 @@
1
1
  import { z } from "zod";
2
- import { createRegisterSimpleTool, createZodToolRegistrar } from "../../../shared/mcp-tool-kit.ts";
2
+ import { type ConsentServer, createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
3
+ import {
4
+ createRegisterSimpleTool,
5
+ createZodToolRegistrar,
6
+ mcpJsonResult,
7
+ requireProcessEnv,
8
+ } from "../../../shared/mcp-tool-kit.ts";
3
9
  import {
4
10
  makeRestToolRegistrar,
5
11
  type RestFetchResult,
@@ -25,10 +31,32 @@ async function discordFetch(
25
31
  return toRestFetchResult(res);
26
32
  }
27
33
 
28
- export function registerDiscordTools(server: { tool: (...args: never) => unknown }): void {
34
+ /** Tool names exposed by this connector — for contract/introspection tests. */
35
+ export const DISCORD_TOOL_NAMES = [
36
+ "discord_guild_list",
37
+ "discord_channel_list",
38
+ "discord_channel_messages",
39
+ "discord_thread_list",
40
+ "discord_message_post",
41
+ ] as const;
42
+
43
+ export function registerDiscordTools(
44
+ server: ConsentServer & { tool: (...args: never) => unknown },
45
+ ): void {
29
46
  const registerSimpleTool = createRegisterSimpleTool(server);
30
47
  const reg = createZodToolRegistrar(registerSimpleTool);
31
48
 
49
+ /**
50
+ * Every MUTATING discord tool goes through here. Outside the gateway this adds the consent gate,
51
+ * the write-scope allow-list (`channel:<id>`), the mutation budget and the audit record; inside
52
+ * the gateway it is a pass-through, because executor.ts (I2) is the gate there.
53
+ */
54
+ const registerWriteTool = createWriteToolRegistrar(server, {
55
+ connector: "discord",
56
+ scopeEnv: "NIMBUS_MCP_DISCORD_WRITE_SCOPE",
57
+ scopeKinds: ["channel"],
58
+ });
59
+
32
60
  /** Standard Discord read tool: token → discordFetch(buildPath) → mcpJsonResultIfOk("Discord"). */
33
61
  const registerDiscordTool = makeRestToolRegistrar({
34
62
  registrar: reg,
@@ -79,4 +107,38 @@ export function registerDiscordTools(server: { tool: (...args: never) => unknown
79
107
  z.object({ guildId: z.string().min(1) }),
80
108
  (parsed) => `/guilds/${encodeURIComponent(parsed.guildId)}/threads/active`,
81
109
  );
110
+
111
+ registerWriteTool(
112
+ "discord_message_post",
113
+ {
114
+ mutates: "discord.message.post",
115
+ // A posted message is visible to the channel at once and cannot be unsent from here, but the
116
+ // post is the whole effect and its id is returned, so it is not an unrecoverable data loss.
117
+ recoverable: true,
118
+ scopeTargetOf: (p) => ({ kind: "channel", value: p.channelId }),
119
+ },
120
+ "Post a message to a Discord channel AS THE BOT (not as the user). Only user mentions are active: @everyone, @here and role mentions are sent as plain text.",
121
+ z.object({
122
+ channelId: z.string().regex(/^\d{17,20}$/, "channelId is a Discord snowflake (17-20 digits)"),
123
+ content: z.string().min(1).max(2000),
124
+ }),
125
+ async (parsed) => {
126
+ const token = requireProcessEnv("DISCORD_BOT_TOKEN");
127
+ const res = await discordFetch(token, `/channels/${parsed.channelId}/messages`, {
128
+ method: "POST",
129
+ headers: { "Content-Type": "application/json" },
130
+ body: JSON.stringify({
131
+ content: parsed.content,
132
+ // Owner ruling: user mentions only. Without an explicit `parse` Discord resolves every
133
+ // mention in the text, so a model-written "@everyone" would ping the whole guild.
134
+ allowed_mentions: { parse: ["users"] },
135
+ }),
136
+ });
137
+ if (!res.ok) {
138
+ throw new Error(`Discord ${String(res.status)}: ${res.text.slice(0, 300)}`);
139
+ }
140
+ const created = res.json as { id?: unknown } | null;
141
+ return mcpJsonResult({ id: created?.id, channelId: parsed.channelId });
142
+ },
143
+ );
82
144
  }
@@ -28,6 +28,10 @@ export const GITHUB_TOOL_NAMES = [
28
28
  "github_issue_list",
29
29
  "github_issue_get",
30
30
  "github_issue_create",
31
+ "github_issue_comment_create",
32
+ "github_issue_labels_add",
33
+ "github_issue_assignees_add",
34
+ "github_issue_state_set",
31
35
  "github_ci_runs",
32
36
  "github_ci_run_get",
33
37
  "github_branch_delete",
@@ -214,6 +218,52 @@ export function registerGithubTools(
214
218
  (parsed) => jsonInit("POST", { title: parsed.title, body: parsed.body }),
215
219
  );
216
220
 
221
+ const githubIssueNumberSchema = repoSlugArgs.extend({
222
+ issueNumber: z.number().int().min(1),
223
+ });
224
+ const issuePath = (parsed: z.infer<typeof githubIssueNumberSchema>): string =>
225
+ `${ghRepoPath(parsed.owner, parsed.repo)}/issues/${String(parsed.issueNumber)}`;
226
+
227
+ registerGithubWriteTool(
228
+ "github_issue_comment_create",
229
+ { mutates: "github.issue.comment.create", recoverable: true },
230
+ "Add a comment to an issue or a pull request's conversation (pull requests are issues to GitHub); it posts as the authenticated user and changes nothing else.",
231
+ githubIssueNumberSchema.extend({ body: z.string().min(1).max(65_000) }),
232
+ (parsed) => `${issuePath(parsed)}/comments`,
233
+ (parsed) => jsonInit("POST", { body: parsed.body }),
234
+ );
235
+
236
+ registerGithubWriteTool(
237
+ "github_issue_labels_add",
238
+ { mutates: "github.issue.labels.add", recoverable: true },
239
+ "Add labels to an issue or pull request; a label that does not exist yet is created in the repository. It only adds, and cannot remove or replace any existing label.",
240
+ githubIssueNumberSchema.extend({
241
+ labels: z.array(z.string().min(1).max(50)).min(1).max(100),
242
+ }),
243
+ (parsed) => `${issuePath(parsed)}/labels`,
244
+ (parsed) => jsonInit("POST", { labels: parsed.labels }),
245
+ );
246
+
247
+ registerGithubWriteTool(
248
+ "github_issue_assignees_add",
249
+ { mutates: "github.issue.assignees.add", recoverable: true },
250
+ "Add assignees to an issue or pull request; it only adds, and cannot remove any existing assignee.",
251
+ githubIssueNumberSchema.extend({
252
+ assignees: z.array(z.string().min(1).max(39)).min(1).max(10),
253
+ }),
254
+ (parsed) => `${issuePath(parsed)}/assignees`,
255
+ (parsed) => jsonInit("POST", { assignees: parsed.assignees }),
256
+ );
257
+
258
+ registerGithubWriteTool(
259
+ "github_issue_state_set",
260
+ { mutates: "github.issue.state.set", recoverable: true },
261
+ "Open or close an issue (state open or closed); given a pull request number it closes or reopens that pull request. It changes only the state and can be reversed by setting it back.",
262
+ githubIssueNumberSchema.extend({ state: z.enum(["open", "closed"]) }),
263
+ issuePath,
264
+ (parsed) => jsonInit("PATCH", { state: parsed.state }),
265
+ );
266
+
217
267
  const githubCiRunsSchema = repoSlugArgs.extend({
218
268
  perPage: z.number().int().min(1).max(100).optional(),
219
269
  page: z.number().int().min(1).optional(),
@@ -3,8 +3,8 @@ import type { ConsentServer } from "../../../shared/consent-kit.ts";
3
3
  import { createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
4
4
  import {
5
5
  GH_ACCEPT,
6
- GH_API,
7
6
  GH_API_VERSION,
7
+ ghApiBase,
8
8
  ghFetch,
9
9
  ghQueryPath,
10
10
  ghRepoPath,
@@ -122,15 +122,27 @@ export function registerGithubActionsTools(
122
122
  }),
123
123
  async (parsed) => {
124
124
  const token = requireProcessEnv("GITHUB_PAT");
125
- const url = `${GH_API}${ghRepoPath(parsed.owner, parsed.repo)}/actions/jobs/${String(parsed.jobId)}/logs`;
126
- const res = await fetch(url, {
125
+ const url = `${ghApiBase()}${ghRepoPath(parsed.owner, parsed.repo)}/actions/jobs/${String(parsed.jobId)}/logs`;
126
+ // The log lives in third-party storage behind a redirect, so the redirect is followed BY HAND:
127
+ // the token goes to the API host only, never to wherever the Location points.
128
+ let res = await fetch(url, {
127
129
  headers: {
128
130
  Accept: GH_ACCEPT,
129
131
  Authorization: `Bearer ${token}`,
130
132
  "X-GitHub-Api-Version": GH_API_VERSION,
131
133
  },
132
- redirect: "follow",
134
+ redirect: "manual",
133
135
  });
136
+ const location = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
137
+ if (location !== null) {
138
+ const target = new URL(location, url);
139
+ // The Location is the server's to choose: only https (or http when the API base itself
140
+ // is http, e.g. an internal GHES) is followed, never file:, ftp: or the like.
141
+ if (target.protocol !== "https:" && target.protocol !== new URL(url).protocol) {
142
+ throw new Error(`GitHub Actions logs: refusing to follow a ${target.protocol} redirect`);
143
+ }
144
+ res = await fetch(target.href, { redirect: "follow" });
145
+ }
134
146
  const text = await res.text();
135
147
  if (!res.ok) {
136
148
  throw new Error(`GitHub Actions logs ${String(res.status)}: ${text.slice(0, 400)}`);
@@ -76,6 +76,35 @@ const stateListUrl =
76
76
  setPaging(q, parsed);
77
77
  });
78
78
 
79
+ /** `<origin>/api/graphql` for the configured REST base, with or without its `/api/v4` suffix. */
80
+ function graphqlUrl(): string {
81
+ return `${apiBase().replace(/\/api\/v4$/, "")}/api/graphql`;
82
+ }
83
+
84
+ /** APPEND is the whole point: REPLACE would drop the assignees already on the issue. */
85
+ const SET_ASSIGNEES_APPEND =
86
+ "mutation($projectPath: ID!, $iid: String!, $assigneeUsernames: [String!]!) {" +
87
+ " issueSetAssignees(input: { projectPath: $projectPath, iid: $iid," +
88
+ " assigneeUsernames: $assigneeUsernames, operationMode: APPEND }) { errors } }";
89
+
90
+ /** The first GraphQL failure in a response (transport-level or the mutation's own), if any. */
91
+ function graphqlFailure(json: unknown): string | undefined {
92
+ const body = json as {
93
+ errors?: readonly { message?: string }[];
94
+ data?: { issueSetAssignees?: { errors?: readonly string[] } | null } | null;
95
+ } | null;
96
+ if (body?.errors !== undefined && body.errors.length > 0) {
97
+ return body.errors.map((e) => e.message ?? "unknown error").join("; ");
98
+ }
99
+ const inner = body?.data?.issueSetAssignees;
100
+ if (inner === undefined || inner === null) {
101
+ return "no issueSetAssignees result";
102
+ }
103
+ return inner.errors !== undefined && inner.errors.length > 0
104
+ ? inner.errors.join("; ")
105
+ : undefined;
106
+ }
107
+
79
108
  /** A CI job's whole plain-text trace; throws, quoting GitLab, on a non-ok status. */
80
109
  async function fetchJobTrace(parsed: {
81
110
  readonly projectPath: string;
@@ -100,6 +129,12 @@ export const GITLAB_TOOL_NAMES = [
100
129
  "gitlab_mr_merge",
101
130
  "gitlab_issue_list",
102
131
  "gitlab_issue_get",
132
+ "gitlab_issue_create",
133
+ "gitlab_issue_note_create",
134
+ "gitlab_mr_note_create",
135
+ "gitlab_issue_labels_add",
136
+ "gitlab_issue_assignees_add",
137
+ "gitlab_issue_state_set",
103
138
  "gitlab_pipeline_list",
104
139
  "gitlab_pipeline_get",
105
140
  "gitlab_pipeline_jobs_get",
@@ -270,6 +305,122 @@ export function registerGitlabTools(
270
305
  `/projects/${encodeURIComponent(parsed.projectPath)}/issues/${String(parsed.issueIid)}`,
271
306
  );
272
307
 
308
+ /** One label: GitLab joins a label list with commas, so a comma inside one would split it in two. */
309
+ const labelSchema = z
310
+ .string()
311
+ .min(1)
312
+ .max(255)
313
+ .refine((l) => !l.includes(","), "a label cannot contain a comma");
314
+ const assigneeIdSchema = z.number().int().min(1);
315
+ const noteBodySchema = z.string().min(1).max(65_000);
316
+ const issuePath = (p: { projectPath: string; issueIid: number }): string =>
317
+ `/projects/${encodeURIComponent(p.projectPath)}/issues/${String(p.issueIid)}`;
318
+ const jsonBody = (method: "POST" | "PUT", body: unknown): RequestInit => ({
319
+ method,
320
+ headers: { "Content-Type": "application/json" },
321
+ body: JSON.stringify(body),
322
+ });
323
+
324
+ registerGitlabWriteTool(
325
+ "gitlab_issue_create",
326
+ { mutates: "gitlab.issue.create", recoverable: true },
327
+ "Create a new issue in a project, optionally with labels and assignees.",
328
+ projectPathArg.extend({
329
+ title: z.string().min(1).max(255),
330
+ description: z.string().max(65_000).optional(),
331
+ labels: z.array(labelSchema).max(50).optional(),
332
+ assigneeIds: z.array(assigneeIdSchema).max(20).optional(),
333
+ }),
334
+ (parsed) => `/projects/${encodeURIComponent(parsed.projectPath)}/issues`,
335
+ (parsed) =>
336
+ jsonBody("POST", {
337
+ title: parsed.title,
338
+ description: parsed.description,
339
+ labels: parsed.labels?.join(","),
340
+ assignee_ids: parsed.assigneeIds,
341
+ }),
342
+ );
343
+
344
+ registerGitlabWriteTool(
345
+ "gitlab_issue_note_create",
346
+ { mutates: "gitlab.issue.note.create", recoverable: true },
347
+ "Add a comment (note) to an issue; it posts as the authenticated user and changes nothing else.",
348
+ gitlabIssueGetSchema.extend({ body: noteBodySchema }),
349
+ (parsed) => `${issuePath(parsed)}/notes`,
350
+ (parsed) => jsonBody("POST", { body: parsed.body }),
351
+ );
352
+
353
+ registerGitlabWriteTool(
354
+ "gitlab_mr_note_create",
355
+ { mutates: "gitlab.mr.note.create", recoverable: true },
356
+ "Add a comment (note) to a merge request; it posts as the authenticated user and changes nothing else.",
357
+ gitlabMrGetSchema.extend({ body: noteBodySchema }),
358
+ (parsed) =>
359
+ `/projects/${encodeURIComponent(parsed.projectPath)}/merge_requests/${String(parsed.mergeRequestIid)}/notes`,
360
+ (parsed) => jsonBody("POST", { body: parsed.body }),
361
+ );
362
+
363
+ registerGitlabWriteTool(
364
+ "gitlab_issue_labels_add",
365
+ { mutates: "gitlab.issue.labels.add", recoverable: true },
366
+ "Add labels to an issue; it only adds, and cannot remove or replace any existing label.",
367
+ gitlabIssueGetSchema.extend({ labels: z.array(labelSchema).min(1).max(50) }),
368
+ issuePath,
369
+ (parsed) => jsonBody("PUT", { add_labels: parsed.labels.join(",") }),
370
+ );
371
+
372
+ // REST "Update an issue" can only REPLACE assignees (`assignee_ids`), so the add goes through
373
+ // GraphQL's issueSetAssignees in APPEND mode: one atomic server-side add that removes nobody.
374
+ registerWriteTool(
375
+ "gitlab_issue_assignees_add",
376
+ {
377
+ mutates: "gitlab.issue.assignees.add",
378
+ recoverable: true,
379
+ scopeTargetOf: (p) => ({ kind: "repo", value: p.projectPath }),
380
+ },
381
+ "Add assignees (usernames) to an issue; it only adds, and cannot remove any existing assignee.",
382
+ gitlabIssueGetSchema.extend({
383
+ assigneeUsernames: z.array(z.string().min(1).max(255)).min(1).max(20),
384
+ }),
385
+ async (parsed) => {
386
+ const token = requireProcessEnv("GITLAB_PAT");
387
+ // GitLab documents `Authorization: Bearer` for /api/graphql (PRIVATE-TOKEN is the REST
388
+ // header), so this one request bypasses glFetch. Never the private_token query parameter:
389
+ // a token in a URL is logged.
390
+ const res = await toRestFetchResult(
391
+ await fetch(graphqlUrl(), {
392
+ method: "POST",
393
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
394
+ body: JSON.stringify({
395
+ query: SET_ASSIGNEES_APPEND,
396
+ variables: {
397
+ projectPath: parsed.projectPath,
398
+ iid: String(parsed.issueIid),
399
+ assigneeUsernames: parsed.assigneeUsernames,
400
+ },
401
+ }),
402
+ }),
403
+ );
404
+ if (!res.ok) {
405
+ throw new Error(`GitLab ${String(res.status)}: ${res.text.slice(0, 300)}`);
406
+ }
407
+ const failure = graphqlFailure(res.json);
408
+ if (failure !== undefined) {
409
+ throw new Error(`GitLab GraphQL: ${failure}`);
410
+ }
411
+ return jsonResult({ ok: true, assignees: parsed.assigneeUsernames });
412
+ },
413
+ );
414
+
415
+ registerGitlabWriteTool(
416
+ "gitlab_issue_state_set",
417
+ { mutates: "gitlab.issue.state.set", recoverable: true },
418
+ "Close or reopen an issue (stateEvent close or reopen); it changes only the state and can be reversed.",
419
+ gitlabIssueGetSchema.extend({ stateEvent: z.enum(["close", "reopen"]) }),
420
+ issuePath,
421
+ (parsed) => jsonBody("PUT", { state_event: parsed.stateEvent }),
422
+ );
423
+
273
424
  const gitlabPipelineListSchema = projectPathArg.extend({
274
425
  ref: z.string().max(500).optional(),
275
426
  status: z.string().max(64).optional(),
@@ -56,6 +56,8 @@ export const JIRA_TOOL_NAMES = [
56
56
  "jira_issue_create",
57
57
  "jira_issue_update",
58
58
  "jira_comment_add",
59
+ "jira_issue_transitions_list",
60
+ "jira_issue_transition",
59
61
  "jira_board_list",
60
62
  "jira_sprint_list",
61
63
  "jira_epic_list",
@@ -242,6 +244,77 @@ export function registerJiraTools(
242
244
  },
243
245
  );
244
246
 
247
+ reg(
248
+ "jira_issue_transitions_list",
249
+ "List the workflow transitions available to a Jira issue right now, with their ids and required fields (GET /rest/api/3/issue/{key}/transitions); read this before jira_issue_transition.",
250
+ jiraIssueKeySchema,
251
+ async (parsed) => {
252
+ const { baseUrl, email, token } = requireJiraConfig();
253
+ const key = encodeURIComponent(parsed.issueKey);
254
+ const res = await jiraFetch(
255
+ baseUrl,
256
+ email,
257
+ token,
258
+ `/rest/api/3/issue/${key}/transitions?expand=transitions.fields`,
259
+ );
260
+ return mcpJsonResultFromTextIfOk("Jira", res, {
261
+ jsonParseErrorMessage: "Jira: invalid JSON from transitions list",
262
+ });
263
+ },
264
+ );
265
+
266
+ const jiraIssueTransitionSchema = jiraIssueKeySchema.extend({
267
+ transitionId: z
268
+ .string()
269
+ .regex(/^\d{1,20}$/, "transitionId is the numeric id from jira_issue_transitions_list"),
270
+ });
271
+
272
+ registerWriteTool(
273
+ "jira_issue_transition",
274
+ {
275
+ mutates: "jira.issue.transition",
276
+ // Workflows are often one-way (Done cannot always be left), so the status the issue is in
277
+ // BEFORE the move is captured: it is the one thing the audit record needs to reverse it.
278
+ recoverable: false,
279
+ capturePreState: async (parsed) => {
280
+ const { baseUrl, email, token } = requireJiraConfig();
281
+ const key = encodeURIComponent(parsed.issueKey);
282
+ const res = await jiraFetch(
283
+ baseUrl,
284
+ email,
285
+ token,
286
+ `/rest/api/3/issue/${key}?fields=status`,
287
+ );
288
+ if (!res.ok) {
289
+ throw new Error(`Jira ${String(res.status)}: ${res.text.slice(0, 200)}`);
290
+ }
291
+ const json = JSON.parse(res.text) as {
292
+ fields?: { status?: { name?: string; id?: string } };
293
+ };
294
+ return { status: json.fields?.status?.name ?? json.fields?.status?.id };
295
+ },
296
+ scopeTargetOf: (p) => ({ kind: "issue", value: p.issueKey }),
297
+ },
298
+ "Move a Jira issue through its workflow with a transition id from jira_issue_transitions_list (POST /rest/api/3/issue/{key}/transitions); workflows can be one-way, so this may not be reversible.",
299
+ jiraIssueTransitionSchema,
300
+ async (parsed) => {
301
+ const { baseUrl, email, token } = requireJiraConfig();
302
+ const key = encodeURIComponent(parsed.issueKey);
303
+ const res = await jiraFetch(baseUrl, email, token, `/rest/api/3/issue/${key}/transitions`, {
304
+ method: "POST",
305
+ body: JSON.stringify({ transition: { id: parsed.transitionId } }),
306
+ });
307
+ if (!res.ok) {
308
+ throw new Error(`Jira ${String(res.status)}: ${res.text.slice(0, 400)}`);
309
+ }
310
+ return jsonResult({
311
+ ok: true,
312
+ issueKey: parsed.issueKey,
313
+ transitionId: parsed.transitionId,
314
+ });
315
+ },
316
+ );
317
+
245
318
  const jiraBoardListSchema = z.object({
246
319
  startAt: z.number().int().min(0).optional(),
247
320
  maxResults: z.number().int().min(1).max(50).optional(),
@@ -2,7 +2,7 @@
2
2
  "id": "com.nimbus.obsidian",
3
3
  "displayName": "Obsidian",
4
4
  "version": "0.1.0",
5
- "description": "Local Obsidian vault connector (notes, backlinks). No network. Vault paths come from [[filesystem.roots]] and are merged into permissions.filesystem at spawn time by the Gateway. obsidian_append_to_daily_note requires HITL.",
5
+ "description": "Local Obsidian vault connector (notes, backlinks, daily notes). No network. Vault paths come from [[filesystem.roots]] and are merged into permissions.filesystem.read at spawn time by the Gateway, which may also add the vaults to permissions.filesystem.write at spawn (only while the owner has approved obsidian_append_to_daily_note for the model). obsidian_append_to_daily_note requires HITL; obsidian_daily_note_get is a read.",
6
6
  "author": "Nimbus",
7
7
  "entrypoint": "dist/server.js",
8
8
  "runtime": "bun",
@@ -2,11 +2,13 @@ import { createHash } from "node:crypto";
2
2
  import {
3
3
  closeSync,
4
4
  fstatSync,
5
+ lstatSync,
5
6
  mkdirSync,
6
7
  openSync,
7
8
  readdirSync,
8
9
  readFileSync,
9
10
  readSync,
11
+ realpathSync,
10
12
  statSync,
11
13
  writeSync,
12
14
  } from "node:fs";
@@ -54,6 +56,93 @@ function assertWithinVault(vaultRoot: string, relPath: string): string {
54
56
  return candidate;
55
57
  }
56
58
 
59
+ /** Whether anything, a dangling link included, sits at `p`. */
60
+ function pathEntryExists(p: string): boolean {
61
+ try {
62
+ lstatSync(p);
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Refuse `abs` (a path `assertWithinVault` already accepted) when the place it really names is
71
+ * outside the vault: a folder or note that is, or passes through, a symlink or junction out of the
72
+ * vault. `assertWithinVault` compares strings, so it cannot see a link.
73
+ *
74
+ * The note, and the folders above it, may not exist yet (the append creates them), so the check
75
+ * resolves the deepest part of the path that does exist; whatever is created below it is created
76
+ * inside it. A dangling link cannot be resolved and is refused. The check and the later open are
77
+ * two system calls, so a link swapped in between them is not seen here: the gateway's sandbox,
78
+ * which grants only the vaults, is the boundary (stated bound).
79
+ */
80
+ export function assertRealpathWithinVault(vaultRoot: string, abs: string, relPath: string): void {
81
+ const realRoot = realpathSync(resolve(vaultRoot));
82
+ const prefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
83
+ let probe = abs;
84
+ while (!pathEntryExists(probe)) {
85
+ const parent = dirname(probe);
86
+ if (parent === probe) break;
87
+ probe = parent;
88
+ }
89
+ let real: string;
90
+ try {
91
+ real = realpathSync(probe);
92
+ } catch {
93
+ throw new Error(`Path escapes vault: ${relPath}`);
94
+ }
95
+ if (real !== realRoot && !real.startsWith(prefix)) {
96
+ throw new Error(`Path escapes vault: ${relPath}`);
97
+ }
98
+ }
99
+
100
+ /** How much of the end of a daily note `obsidian_daily_note_get` returns. */
101
+ export const DAILY_NOTE_TAIL_BYTES = 16 * 1024;
102
+
103
+ /** The positioned-read signature of `readSync`, injectable so a test can count what is read. */
104
+ export type PositionedRead = (
105
+ fd: number,
106
+ buffer: Buffer,
107
+ offset: number,
108
+ length: number,
109
+ position: number,
110
+ ) => number;
111
+
112
+ /**
113
+ * The size of the file at `abs` and its last `maxBytes` bytes, decoded as UTF-8.
114
+ *
115
+ * Positioned reads of at most `maxBytes` in all, never the whole file. When the window starts
116
+ * inside the file, the UTF-8 continuation bytes it begins with (the rest of a character the window
117
+ * cut) are dropped, so the tail starts on a character boundary.
118
+ */
119
+ export function readUtf8Tail(
120
+ abs: string,
121
+ maxBytes: number,
122
+ read: PositionedRead = readSync,
123
+ ): { size: number; tail: string } {
124
+ const fd = openSync(abs, "r");
125
+ try {
126
+ const size = fstatSync(fd).size;
127
+ const start = Math.max(0, size - maxBytes);
128
+ const length = size - start;
129
+ const buf = Buffer.alloc(length);
130
+ let got = 0;
131
+ while (got < length) {
132
+ const n = read(fd, buf, got, length - got, start + got);
133
+ if (n <= 0) break;
134
+ got += n;
135
+ }
136
+ let skip = 0;
137
+ if (start > 0) {
138
+ while (skip < 3 && skip < got && ((buf[skip] ?? 0) & 0xc0) === 0x80) skip++;
139
+ }
140
+ return { size, tail: buf.subarray(skip, got).toString("utf8") };
141
+ } finally {
142
+ closeSync(fd);
143
+ }
144
+ }
145
+
57
146
  function loadVaultPaths(): readonly string[] {
58
147
  const raw = requireProcessEnv("OBSIDIAN_VAULT_PATHS_JSON");
59
148
  let parsed: unknown;
@@ -98,7 +187,10 @@ function walkForVaults(dir: string, out: VaultEntry[]): void {
98
187
  const sub = join(dir, e);
99
188
  let isDir = false;
100
189
  try {
101
- const st = statSync(sub);
190
+ // lstat, not stat: stat follows a link, so its isSymbolicLink() was always false and
191
+ // discovery used to find a vault through a symlink or junction outside the configured root.
192
+ // Every link below a root is skipped, so a discovered vault is physically under its root.
193
+ const st = lstatSync(sub);
102
194
  isDir = st.isDirectory() && !st.isSymbolicLink();
103
195
  } catch {
104
196
  continue;
@@ -128,7 +220,9 @@ function walkNotes(currentDir: string, vaultRoot: string, out: string[]): void {
128
220
  let isFile = false;
129
221
  let isDir = false;
130
222
  try {
131
- const st = statSync(full);
223
+ // lstat, not stat: stat follows a link, so its isSymbolicLink() is always false and the
224
+ // walk used to list notes through a symlink or junction, out of the vault included.
225
+ const st = lstatSync(full);
132
226
  if (st.isSymbolicLink()) continue;
133
227
  isFile = st.isFile();
134
228
  isDir = st.isDirectory();
@@ -151,6 +245,7 @@ function readNote(
151
245
  relPath: string,
152
246
  ): { title: string; body: string; raw: string } {
153
247
  const abs = assertWithinVault(vaultRoot, relPath);
248
+ assertRealpathWithinVault(vaultRoot, abs, relPath);
154
249
  const raw = readFileSync(abs, "utf8");
155
250
  const m = FRONTMATTER_RE.exec(raw);
156
251
  const body = m === null ? raw : raw.slice(m[0].length);
@@ -193,6 +288,7 @@ export const OBSIDIAN_TOOL_NAMES = [
193
288
  "obsidian_get",
194
289
  "obsidian_search",
195
290
  "obsidian_append_to_daily_note",
291
+ "obsidian_daily_note_get",
196
292
  ] as const;
197
293
 
198
294
  /** The date tokens an Obsidian daily-note filename format may contain. */
@@ -400,6 +496,28 @@ export function registerObsidianTools(
400
496
  }),
401
497
  );
402
498
 
499
+ /**
500
+ * The vault and the daily note a daily-note tool names: ONE resolution, shared by the append and
501
+ * the read, so the read shows exactly the note the append would write. String containment
502
+ * (`assertWithinVault`) and then realpath containment (`assertRealpathWithinVault`).
503
+ */
504
+ function resolveDailyNote(parsed: { vault_id: string; date_iso?: string | undefined }): {
505
+ v: VaultEntry;
506
+ rel: string;
507
+ abs: string;
508
+ } {
509
+ const v = findVaultByIdOrPathPrefix(VAULTS, parsed.vault_id);
510
+ if (v === undefined) {
511
+ throw new Error("Unknown vault_id");
512
+ }
513
+ const date =
514
+ parsed.date_iso === undefined ? new Date() : new Date(`${parsed.date_iso}T00:00:00Z`);
515
+ const rel = resolveDailyNoteRelativePath(v.root, date);
516
+ const abs = assertWithinVault(v.root, rel);
517
+ assertRealpathWithinVault(v.root, abs, rel);
518
+ return { v, rel, abs };
519
+ }
520
+
403
521
  const appendDailyNoteSchema = z.object({
404
522
  vault_id: z.string().min(1),
405
523
  content: z.string().min(1),
@@ -420,15 +538,10 @@ export function registerObsidianTools(
420
538
  "Append text to today's Obsidian daily note. Creates the file if it does not exist. Always appends — never overwrites. Adds a leading newline when the existing file does not end in one. Requires HITL `obsidian.note.append`.",
421
539
  appendDailyNoteSchema,
422
540
  syncToolHandler((parsed) => {
423
- const v = findVaultByIdOrPathPrefix(VAULTS, parsed.vault_id);
424
- if (v === undefined) {
425
- throw new Error("Unknown vault_id");
426
- }
427
- const date =
428
- parsed.date_iso === undefined ? new Date() : new Date(`${parsed.date_iso}T00:00:00Z`);
429
- const rel = resolveDailyNoteRelativePath(v.root, date);
430
- const abs = assertWithinVault(v.root, rel);
541
+ const { v, rel, abs } = resolveDailyNote(parsed);
431
542
  mkdirSync(dirname(abs), { recursive: true });
543
+ // Again, now the folders exist: what was created must still be inside the vault.
544
+ assertRealpathWithinVault(v.root, abs, rel);
432
545
 
433
546
  const fd = openSync(abs, "a+");
434
547
  let bytes = 0;
@@ -463,4 +576,39 @@ export function registerObsidianTools(
463
576
  });
464
577
  }),
465
578
  );
579
+
580
+ const dailyNoteGetSchema = z.object({
581
+ vault_id: z.string().min(1),
582
+ date_iso: z
583
+ .string()
584
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
585
+ .optional(),
586
+ });
587
+
588
+ reg(
589
+ "obsidian_daily_note_get",
590
+ `Read the end of the Obsidian daily note that obsidian_append_to_daily_note would write for the same vault_id and date_iso (today in UTC when omitted). Returns { vault_id, vault_name, path, exists, size_bytes, tail }: tail is at most the last ${String(DAILY_NOTE_TAIL_BYTES)} bytes, starting on a character boundary; a missing note is exists: false, size_bytes: 0, tail "". Read-only.`,
591
+ dailyNoteGetSchema,
592
+ syncToolHandler((parsed) => {
593
+ const { v, rel, abs } = resolveDailyNote(parsed);
594
+ let exists = false;
595
+ let size = 0;
596
+ let tail = "";
597
+ if (pathEntryExists(abs)) {
598
+ if (!statSync(abs).isFile()) {
599
+ throw new Error(`Daily note is not a file: ${rel}`);
600
+ }
601
+ exists = true;
602
+ ({ size, tail } = readUtf8Tail(abs, DAILY_NOTE_TAIL_BYTES));
603
+ }
604
+ return jsonResult({
605
+ vault_id: v.id,
606
+ vault_name: v.name,
607
+ path: rel,
608
+ exists,
609
+ size_bytes: size,
610
+ tail,
611
+ });
612
+ }),
613
+ );
466
614
  }
@@ -3,6 +3,9 @@ const OUTLOOK_TOOL_MIN_SCOPES: Readonly<Record<string, readonly string[]>> = {
3
3
  outlook_mail_list: ["Mail.Read"],
4
4
  outlook_mail_read: ["Mail.Read"],
5
5
  outlook_mail_send: ["Mail.Send"],
6
+ // A draft is written into the mailbox, which Mail.Send alone does not allow. Mail.ReadWrite
7
+ // (owner ruling 2026-11-05) lets the tool create one; it cannot send it.
8
+ outlook_draft_create: ["Mail.ReadWrite"],
6
9
  outlook_calendar_list: ["Calendars.Read"],
7
10
  outlook_calendar_get: ["Calendars.Read"],
8
11
  outlook_calendar_create: ["Calendars.ReadWrite"],
@@ -37,12 +37,54 @@ function graphRequest(
37
37
  return makeRestFetcher({ apiBase: GRAPH, token })(pathOrUrl, init);
38
38
  }
39
39
 
40
+ /** `a@x, ,b@y` as Graph recipients, empty entries dropped. */
41
+ function graphRecipients(list: string): { emailAddress: { address: string } }[] {
42
+ return list
43
+ .split(",")
44
+ .map((s) => s.trim())
45
+ .filter((s) => s.length > 0)
46
+ .map((address) => ({ emailAddress: { address } }));
47
+ }
48
+
49
+ /** What `outlook_mail_send` and `outlook_draft_create` both build the message from. */
50
+ interface MailFields {
51
+ readonly to: string;
52
+ readonly subject: string;
53
+ readonly body: string;
54
+ readonly contentType?: "text" | "html" | undefined;
55
+ readonly cc?: string | undefined;
56
+ readonly bcc?: string | undefined;
57
+ }
58
+
59
+ /**
60
+ * The Graph `message` resource: the body of `POST /me/messages` (a draft) and the `message` member
61
+ * of `POST /me/sendMail`. cc and bcc are left out when absent or empty.
62
+ */
63
+ function graphMessage(data: MailFields): Record<string, unknown> {
64
+ const message: Record<string, unknown> = {
65
+ subject: data.subject,
66
+ body: {
67
+ contentType: (data.contentType ?? "text") === "html" ? "HTML" : "Text",
68
+ content: data.body,
69
+ },
70
+ toRecipients: graphRecipients(data.to),
71
+ };
72
+ if (data.cc !== undefined && data.cc !== "") {
73
+ message["ccRecipients"] = graphRecipients(data.cc);
74
+ }
75
+ if (data.bcc !== undefined && data.bcc !== "") {
76
+ message["bccRecipients"] = graphRecipients(data.bcc);
77
+ }
78
+ return message;
79
+ }
80
+
40
81
  /** Tool names exposed by this connector — for contract/introspection tests. */
41
82
  export const OUTLOOK_TOOL_NAMES = [
42
83
  "outlook_mail_folders",
43
84
  "outlook_mail_list",
44
85
  "outlook_mail_read",
45
86
  "outlook_mail_send",
87
+ "outlook_draft_create",
46
88
  "outlook_calendar_list",
47
89
  "outlook_calendar_get",
48
90
  "outlook_calendar_create",
@@ -170,33 +212,10 @@ export function registerOutlookTools(
170
212
  outlookMailSendArgs,
171
213
  async (data) => {
172
214
  const token = requireProcessEnv("MICROSOFT_OAUTH_ACCESS_TOKEN");
173
- const toList = data.to
174
- .split(",")
175
- .map((s) => s.trim())
176
- .filter((s) => s.length > 0);
177
- const message: Record<string, unknown> = {
178
- subject: data.subject,
179
- body: {
180
- contentType: (data.contentType ?? "text") === "html" ? "HTML" : "Text",
181
- content: data.body,
182
- },
183
- toRecipients: toList.map((addr) => ({
184
- emailAddress: { address: addr },
185
- })),
186
- };
187
- if (data.cc !== undefined && data.cc !== "") {
188
- const ccList = data.cc
189
- .split(",")
190
- .map((s) => s.trim())
191
- .filter((s) => s.length > 0);
192
- message["ccRecipients"] = ccList.map((addr) => ({
193
- emailAddress: { address: addr },
194
- }));
195
- }
196
215
  const r = await graphRequest(token, "/me/sendMail", {
197
216
  method: "POST",
198
217
  headers: { "Content-Type": "application/json" },
199
- body: JSON.stringify({ message, saveToSentItems: true }),
218
+ body: JSON.stringify({ message: graphMessage(data), saveToSentItems: true }),
200
219
  });
201
220
  if (!r.ok) {
202
221
  throw new Error(`Graph ${String(r.status)}: ${r.text.slice(0, 200)}`);
@@ -206,6 +225,37 @@ export function registerOutlookTools(
206
225
  );
207
226
  }
208
227
 
228
+ const outlookDraftCreateArgs = outlookMailSendArgs.extend({
229
+ bcc: headerLine({ max: 2000 }).optional(),
230
+ });
231
+
232
+ if (outlookToolShouldRegister("outlook_draft_create", grantedOutlookScopes)) {
233
+ registerWriteTool(
234
+ "outlook_draft_create",
235
+ {
236
+ mutates: "outlook.draft.create",
237
+ // The draft sits in Drafts until the owner deletes or sends it, and nothing is sent.
238
+ recoverable: true,
239
+ scopeTargetOf: (p) => ({ kind: "recipient", value: p.to }),
240
+ },
241
+ "Save an email as a DRAFT in the owner's Drafts folder via Microsoft Graph; it is not sent, and the owner reviews and sends it from Outlook.",
242
+ outlookDraftCreateArgs,
243
+ async (data) => {
244
+ const token = requireProcessEnv("MICROSOFT_OAUTH_ACCESS_TOKEN");
245
+ const r = await graphRequest(token, "/me/messages", {
246
+ method: "POST",
247
+ headers: { "Content-Type": "application/json" },
248
+ body: JSON.stringify(graphMessage(data)),
249
+ });
250
+ if (!r.ok) {
251
+ throw new Error(`Graph ${String(r.status)}: ${r.text.slice(0, 200)}`);
252
+ }
253
+ const created = r.json as { id?: unknown; webLink?: unknown } | null;
254
+ return mcpJsonResult({ id: created?.id, webLink: created?.webLink });
255
+ },
256
+ );
257
+ }
258
+
209
259
  const outlookCalendarListArgs = z.object({
210
260
  startDateTime: z.string().min(1),
211
261
  endDateTime: z.string().min(1),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "com.nimbus.readwise",
3
3
  "displayName": "Readwise",
4
- "version": "0.2.0",
4
+ "version": "0.2.1",
5
5
  "description": "Readwise connector (read-only). Surfaces the user's saved book/article highlights as `readwise:highlight` items in the local index — the highlighted excerpt text, the user's note, the parent book/article id, the location + location type, the color, tags, and the source article URL (for web highlights) — plus their parent books/articles as `readwise:book` items (title, author, category, source, highlight count, document note, tags, ASIN, source + Readwise book-review URLs), so a highlight's `book_id` resolves to a real indexed record. Useful for answering reading questions (\"what did I highlight about retries?\", \"which book was that note in?\") without leaving Nimbus. Token API-token auth (`Authorization: Token <token>` — Django-REST-Framework token auth, NOT Bearer). The SaaS host (readwise.io) is fixed. Readwise paginates both `/api/v2/highlights/` and `/api/v2/books/` by page number (DRF `{ count, next, previous, results }` envelope, page_size=1000). `highlighted_at` / `last_highlight_at` / `updated` are ISO-8601 (converted to epoch-ms on index). Book `external_id`s are namespaced `book/<id>` because Readwise numbers books and highlights in separate sequences. Documents / daily-review and the Reader v3 API remain deferred follow-ups.",
6
6
  "author": "Nimbus",
7
7
  "entrypoint": "dist/server.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nimbus-dev/connectors",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -8,10 +8,19 @@
8
8
  * places, so a bump had to find all three.
9
9
  */
10
10
 
11
+ import { optionalBaseUrl } from "./env-json-api.ts";
11
12
  import { makeRestFetcher, type RestFetchResult } from "./rest-tool-kit.ts";
12
13
 
13
14
  export const GH_API = "https://api.github.com";
14
15
 
16
+ /**
17
+ * The API base in force: `GITHUB_API_BASE_URL` (a GitHub Enterprise Server `https://host/api/v3`),
18
+ * else {@link GH_API}. Read per call, so a test or an operator can change it without a restart.
19
+ */
20
+ export function ghApiBase(): string {
21
+ return optionalBaseUrl("GITHUB_API_BASE_URL", GH_API);
22
+ }
23
+
15
24
  /** The media type GitHub documents for its REST API. */
16
25
  export const GH_ACCEPT = "application/vnd.github+json";
17
26
 
@@ -24,9 +33,9 @@ export const GH_HEADERS: Record<string, string> = {
24
33
  "X-GitHub-Api-Version": GH_API_VERSION,
25
34
  };
26
35
 
27
- /** A Bearer-authenticated JSON request against {@link GH_API} (a relative path or a same-origin URL). */
36
+ /** A Bearer-authenticated JSON request against {@link ghApiBase} (a relative path or a same-origin URL). */
28
37
  export function ghFetch(token: string, path: string, init?: RequestInit): Promise<RestFetchResult> {
29
- return makeRestFetcher({ apiBase: GH_API, token, defaultHeaders: GH_HEADERS })(path, init);
38
+ return makeRestFetcher({ apiBase: ghApiBase(), token, defaultHeaders: GH_HEADERS })(path, init);
30
39
  }
31
40
 
32
41
  /** `/repos/<owner>/<repo>`, both segments URI-encoded. */
@@ -56,6 +65,8 @@ export function setGhPaging(params: URLSearchParams, paging: GhPaging): void {
56
65
  * parameters keep the order `setQuery` sets them in.
57
66
  */
58
67
  export function ghQueryPath(path: string, setQuery: (params: URLSearchParams) => void): string {
68
+ // Parsed against the FIXED public origin, never the configured base: the result is relative to
69
+ // the base ghFetch prepends, so a GHES `/api/v3` must not be in it.
59
70
  const u = new URL(`${GH_API}${path}`);
60
71
  setQuery(u.searchParams);
61
72
  return `${u.pathname}${u.search}`;