@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.
- package/connectors/apple/src/calendar-tools.ts +53 -9
- package/connectors/confluence/src/tools.ts +134 -3
- package/connectors/discord/nimbus.extension.json +2 -2
- package/connectors/discord/src/server.ts +14 -1
- package/connectors/discord/src/tools.ts +64 -2
- package/connectors/github/src/tools.ts +50 -0
- package/connectors/github-actions/src/tools.ts +16 -4
- package/connectors/gitlab/src/tools.ts +151 -0
- package/connectors/jira/src/tools.ts +73 -0
- package/connectors/obsidian/nimbus.extension.json +1 -1
- package/connectors/obsidian/src/tools.ts +158 -10
- package/connectors/outlook/src/tool-scope-policy.ts +3 -0
- package/connectors/outlook/src/tools.ts +74 -24
- package/connectors/readwise/nimbus.extension.json +1 -1
- package/package.json +1 -1
- package/shared/github-rest.ts +13 -2
|
@@ -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:
|
|
48
|
-
summary:
|
|
49
|
-
start: z.string().
|
|
50
|
-
end: z.string().
|
|
51
|
-
description:
|
|
52
|
-
location:
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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 = `${
|
|
126
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
package/shared/github-rest.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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}`;
|