@nimbus-dev/connectors 0.3.0 → 0.4.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.
@@ -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,36 @@ 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
+
43
74
  /** Tool names exposed by this connector — for contract/introspection tests. */
44
75
  export const CONFLUENCE_TOOL_NAMES = [
45
76
  "confluence_space_list",
@@ -52,6 +83,7 @@ export const CONFLUENCE_TOOL_NAMES = [
52
83
  "confluence_kb_append",
53
84
  "confluence_page_update",
54
85
  "confluence_comment_add",
86
+ "confluence_page_append",
55
87
  ] as const;
56
88
 
57
89
  export function registerConfluenceTools(
@@ -317,4 +349,63 @@ export function registerConfluenceTools(
317
349
  return mcpJsonResultFromTextIfOk("Confluence", res);
318
350
  },
319
351
  );
352
+
353
+ const confluencePageAppendSchema = z.object({
354
+ pageId: z.string().min(1),
355
+ storageHtml: z.string().min(1).max(65_000),
356
+ });
357
+
358
+ registerWriteTool(
359
+ "confluence_page_append",
360
+ {
361
+ mutates: "confluence.page.append",
362
+ // Confluence keeps every version of a page, so the previous body is one click away. No
363
+ // capturePreState on purpose: it would run once, before a retry, and describe a version that
364
+ // may not be the one this call changes.
365
+ recoverable: true,
366
+ scopeTargetOf: (p) => ({ kind: "page", value: p.pageId }),
367
+ },
368
+ "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.",
369
+ confluencePageAppendSchema,
370
+ async (parsed) => {
371
+ const id = encodeURIComponent(parsed.pageId);
372
+ // The fetch helper returns `{ ok, status, text }` without throwing, so the retry lives here.
373
+ // Each attempt re-reads the page, so it appends to the body as it is NOW.
374
+ for (let attempt = 1; attempt <= 2; attempt++) {
375
+ const page = readPageSnapshot(
376
+ await confFetch(`/content/${id}?expand=body.storage,version`),
377
+ );
378
+ const next = page.version + 1;
379
+ const put = await confFetch(`/content/${id}`, {
380
+ method: "PUT",
381
+ body: JSON.stringify({
382
+ type: page.type,
383
+ title: page.title,
384
+ version: { number: next, message: "nimbus" },
385
+ body: {
386
+ storage: { value: page.html + parsed.storageHtml, representation: "storage" },
387
+ },
388
+ }),
389
+ });
390
+ if (put.status === 409 && attempt === 1) {
391
+ continue;
392
+ }
393
+ if (put.status === 409) {
394
+ throw new Error("Confluence: page changed concurrently; nothing appended");
395
+ }
396
+ if (!put.ok) {
397
+ throw new Error(`Confluence ${String(put.status)}: ${put.text.slice(0, 400)}`);
398
+ }
399
+ let version = next;
400
+ try {
401
+ const written = JSON.parse(put.text) as { version?: { number?: number } };
402
+ version = written.version?.number ?? next;
403
+ } catch {
404
+ // A non-JSON success body: the version we sent is the one that was written.
405
+ }
406
+ return mcpJsonResult({ ok: true, pageId: parsed.pageId, version });
407
+ }
408
+ throw new Error("Confluence: page changed concurrently; nothing appended");
409
+ },
410
+ );
320
411
  }
@@ -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(),
@@ -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.4.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}`;