@nimbus-dev/connectors 0.2.4 → 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.
- package/connectors/confluence/src/tools.ts +91 -0
- 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/outlook/src/tool-scope-policy.ts +3 -0
- package/connectors/outlook/src/tools.ts +74 -24
- package/connectors/readwise/nimbus.extension.json +9 -1
- package/connectors/readwise/src/book-mapping.ts +109 -0
- package/connectors/readwise/src/extension.ts +11 -0
- package/connectors/readwise/src/highlight-mapping.ts +88 -0
- package/connectors/readwise/src/readwise-base.ts +6 -0
- package/connectors/readwise/src/record-fields.ts +72 -0
- package/connectors/readwise/src/sync-tool.ts +56 -0
- package/connectors/readwise/src/sync.ts +348 -0
- package/connectors/readwise/src/tools.ts +2 -2
- package/package.json +2 -2
- package/shared/github-rest.ts +13 -2
- package/shared/windows-batch-args.ts +14 -1
|
@@ -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.1
|
|
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",
|
|
@@ -11,5 +11,13 @@
|
|
|
11
11
|
},
|
|
12
12
|
"hitlRequired": [],
|
|
13
13
|
"syncInterval": 600,
|
|
14
|
+
"sync": {
|
|
15
|
+
"contract": "1",
|
|
16
|
+
"intervalSeconds": 600,
|
|
17
|
+
"itemTypes": ["highlight", "book"]
|
|
18
|
+
},
|
|
19
|
+
"credentials": {
|
|
20
|
+
"token": { "env": "READWISE_TOKEN" }
|
|
21
|
+
},
|
|
14
22
|
"minNimbusVersion": "0.2.0"
|
|
15
23
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Readwise book (`GET /api/v2/books/`) as a sync item. Ported from the gateway's
|
|
3
|
+
* `readwise-book-mapping.ts` (Nimbus origin/main 61e034a5) with every field rule unchanged;
|
|
4
|
+
* `test/mapping-parity.test.ts` pins each field to what the gateway stored.
|
|
5
|
+
*
|
|
6
|
+
* `externalId` is `book/<numeric id>`, NOT the bare id. Readwise numbers books and highlights in
|
|
7
|
+
* separate sequences, so book 9001 and highlight 9001 both exist, and the gateway's item key is
|
|
8
|
+
* `<service>:<externalId>`: a bare id would let one type's row overwrite the other's.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
asRecord,
|
|
12
|
+
namedTags,
|
|
13
|
+
numberField,
|
|
14
|
+
parseIsoMs,
|
|
15
|
+
type ReadwiseMappingContext,
|
|
16
|
+
type ReadwiseSyncItem,
|
|
17
|
+
stringField,
|
|
18
|
+
} from "./record-fields.ts";
|
|
19
|
+
|
|
20
|
+
/** Keeps `readwise:book/<id>` distinct from the highlight `readwise:<id>`. */
|
|
21
|
+
export const READWISE_BOOK_EXTERNAL_ID_PREFIX = "book/";
|
|
22
|
+
|
|
23
|
+
/** Trimmed string field, or null when absent/blank. */
|
|
24
|
+
function trimmedField(row: Record<string, unknown>, key: string): string | null {
|
|
25
|
+
const raw = stringField(row, key);
|
|
26
|
+
if (raw === undefined) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const trimmed = raw.trim();
|
|
30
|
+
return trimmed === "" ? null : trimmed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function firstNonNull(candidates: readonly (string | null)[], fallback: string): string {
|
|
34
|
+
for (const candidate of candidates) {
|
|
35
|
+
if (candidate !== null) {
|
|
36
|
+
return candidate;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return fallback;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function mapReadwiseBookToItem(
|
|
43
|
+
raw: unknown,
|
|
44
|
+
ctx: ReadwiseMappingContext,
|
|
45
|
+
): ReadwiseSyncItem | null {
|
|
46
|
+
const row = asRecord(raw);
|
|
47
|
+
if (row === undefined) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const idNum = numberField(row, "id");
|
|
52
|
+
if (idNum === undefined) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const id = String(idNum);
|
|
56
|
+
|
|
57
|
+
const title = trimmedField(row, "title");
|
|
58
|
+
const author = trimmedField(row, "author");
|
|
59
|
+
const category = trimmedField(row, "category");
|
|
60
|
+
const source = trimmedField(row, "source");
|
|
61
|
+
const documentNote = trimmedField(row, "document_note");
|
|
62
|
+
const asin = trimmedField(row, "asin");
|
|
63
|
+
const sourceUrl = trimmedField(row, "source_url");
|
|
64
|
+
const highlightsUrl = trimmedField(row, "highlights_url");
|
|
65
|
+
const numHighlights = numberField(row, "num_highlights") ?? null;
|
|
66
|
+
const tags = namedTags(row["tags"]);
|
|
67
|
+
|
|
68
|
+
const lastHighlightAt = parseIsoMs(row["last_highlight_at"]);
|
|
69
|
+
const updatedAt = parseIsoMs(row["updated"]);
|
|
70
|
+
|
|
71
|
+
const titleText = firstNonNull([title, author], `Book ${id}`);
|
|
72
|
+
const bodyPreview = firstNonNull([documentNote, author, category], titleText);
|
|
73
|
+
|
|
74
|
+
// Kindle/ePub books have no public source URL; the Readwise book-review page
|
|
75
|
+
// is the only stable, user-openable URL for them.
|
|
76
|
+
const canonicalUrl = sourceUrl ?? highlightsUrl;
|
|
77
|
+
|
|
78
|
+
const modifiedAt = updatedAt ?? lastHighlightAt ?? ctx.syncedAt;
|
|
79
|
+
|
|
80
|
+
const metadata: Record<string, unknown> = {
|
|
81
|
+
// NUMBER, not the stringified id: a highlight stores its parent as
|
|
82
|
+
// `metadata.book_id` (a number), and this is the field that joins them.
|
|
83
|
+
book_id: idNum,
|
|
84
|
+
title,
|
|
85
|
+
author,
|
|
86
|
+
category,
|
|
87
|
+
source,
|
|
88
|
+
num_highlights: numHighlights,
|
|
89
|
+
asin,
|
|
90
|
+
tags,
|
|
91
|
+
document_note: documentNote,
|
|
92
|
+
source_url: sourceUrl,
|
|
93
|
+
highlights_url: highlightsUrl,
|
|
94
|
+
last_highlight_at: lastHighlightAt,
|
|
95
|
+
updated_at: updatedAt,
|
|
96
|
+
canonical_url: canonicalUrl,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
type: "book",
|
|
101
|
+
externalId: `${READWISE_BOOK_EXTERNAL_ID_PREFIX}${id}`,
|
|
102
|
+
title: titleText,
|
|
103
|
+
bodyPreview,
|
|
104
|
+
url: canonicalUrl,
|
|
105
|
+
canonicalUrl,
|
|
106
|
+
modifiedAt,
|
|
107
|
+
metadata,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Entry point of the signed Readwise extension (bundled to dist/server.js by `build:extension`).
|
|
2
|
+
// Kept apart from server.ts so the standalone/gateway entry stays untouched; bootstrap only.
|
|
3
|
+
// Only this entry registers the reserved `__nimbus_sync` tool; server.ts users never see it.
|
|
4
|
+
import { runReadOnlyMcpConnector } from "../../../shared/run-read-only-mcp-connector.ts";
|
|
5
|
+
import { registerReadwiseSyncTool } from "./sync-tool.ts";
|
|
6
|
+
import { registerReadwiseTools } from "./tools.ts";
|
|
7
|
+
|
|
8
|
+
await runReadOnlyMcpConnector("nimbus-readwise", (reg) => {
|
|
9
|
+
registerReadwiseTools(reg);
|
|
10
|
+
registerReadwiseSyncTool(reg);
|
|
11
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Readwise highlight (`GET /api/v2/highlights/`) as a sync item. Ported from the gateway's
|
|
3
|
+
* `readwise-highlight-mapping.ts` (Nimbus origin/main 61e034a5) with every field rule unchanged:
|
|
4
|
+
* a migrated user's rows are updated in place only while `type` and `externalId` stay
|
|
5
|
+
* byte-identical, and `test/mapping-parity.test.ts` pins every other field to what the gateway
|
|
6
|
+
* stored. Highlights keep the BARE numeric id as `externalId`.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
asRecord,
|
|
10
|
+
namedTags,
|
|
11
|
+
numberField,
|
|
12
|
+
parseIsoMs,
|
|
13
|
+
type ReadwiseMappingContext,
|
|
14
|
+
type ReadwiseSyncItem,
|
|
15
|
+
stringField,
|
|
16
|
+
} from "./record-fields.ts";
|
|
17
|
+
|
|
18
|
+
const TITLE_MAX = 80;
|
|
19
|
+
|
|
20
|
+
export function mapReadwiseHighlightToItem(
|
|
21
|
+
raw: unknown,
|
|
22
|
+
ctx: ReadwiseMappingContext,
|
|
23
|
+
): ReadwiseSyncItem | null {
|
|
24
|
+
const row = asRecord(raw);
|
|
25
|
+
if (row === undefined) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const idNum = numberField(row, "id");
|
|
30
|
+
if (idNum === undefined) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const id = String(idNum);
|
|
34
|
+
|
|
35
|
+
const text = stringField(row, "text") ?? null;
|
|
36
|
+
const note = stringField(row, "note") ?? null;
|
|
37
|
+
const location = numberField(row, "location") ?? null;
|
|
38
|
+
const locationType = stringField(row, "location_type") ?? null;
|
|
39
|
+
const color = stringField(row, "color") ?? null;
|
|
40
|
+
const bookId = numberField(row, "book_id") ?? null;
|
|
41
|
+
const sourceUrl = stringField(row, "url") ?? null;
|
|
42
|
+
const tags = namedTags(row["tags"]);
|
|
43
|
+
|
|
44
|
+
const highlightedAt = parseIsoMs(row["highlighted_at"]);
|
|
45
|
+
const updatedAt = parseIsoMs(row["updated"]);
|
|
46
|
+
|
|
47
|
+
const trimmedText = text === null ? "" : text.trim();
|
|
48
|
+
let title: string;
|
|
49
|
+
if (trimmedText === "") {
|
|
50
|
+
title = `Highlight ${id}`;
|
|
51
|
+
} else if (trimmedText.length > TITLE_MAX) {
|
|
52
|
+
title = `${trimmedText.slice(0, TITLE_MAX)}…`;
|
|
53
|
+
} else {
|
|
54
|
+
title = trimmedText;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const bodyPreview = note !== null && note !== "" ? note : (text ?? "");
|
|
58
|
+
|
|
59
|
+
const canonicalUrl = sourceUrl !== null && sourceUrl !== "" ? sourceUrl : null;
|
|
60
|
+
|
|
61
|
+
const modifiedAt = updatedAt ?? highlightedAt ?? ctx.syncedAt;
|
|
62
|
+
|
|
63
|
+
const metadata: Record<string, unknown> = {
|
|
64
|
+
highlight_id: id,
|
|
65
|
+
text,
|
|
66
|
+
note,
|
|
67
|
+
book_id: bookId,
|
|
68
|
+
location,
|
|
69
|
+
location_type: locationType,
|
|
70
|
+
color,
|
|
71
|
+
tags,
|
|
72
|
+
source_url: canonicalUrl,
|
|
73
|
+
highlighted_at: highlightedAt,
|
|
74
|
+
updated_at: updatedAt,
|
|
75
|
+
canonical_url: canonicalUrl,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
type: "highlight",
|
|
80
|
+
externalId: id,
|
|
81
|
+
title,
|
|
82
|
+
bodyPreview,
|
|
83
|
+
url: canonicalUrl,
|
|
84
|
+
canonicalUrl,
|
|
85
|
+
modifiedAt,
|
|
86
|
+
metadata,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Readwise origin, in ONE place: the read tools and the sync walk both import it. There is no
|
|
3
|
+
* environment or argument override — the SaaS host is fixed — so the only way to point either at
|
|
4
|
+
* another host is to substitute this module in a test-only build.
|
|
5
|
+
*/
|
|
6
|
+
export const READWISE_BASE = "https://readwise.io";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Field readers for the two Readwise mappers, ported unchanged from the gateway's
|
|
3
|
+
* `unknown-record.ts` and `_lib/field-helpers.ts` (Nimbus origin/main 61e034a5). Each rule is
|
|
4
|
+
* load-bearing for parity with rows the gateway already indexed, so none of them is "simplified":
|
|
5
|
+
* `shared/search-filter.ts` has an `asRecord` of the same rule but a `stringField` that returns ""
|
|
6
|
+
* rather than `undefined`, which would change what a missing field maps to.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function asRecord(v: unknown): Record<string, unknown> | undefined {
|
|
10
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
return v as Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function stringField(r: Record<string, unknown>, key: string): string | undefined {
|
|
17
|
+
const v = r[key];
|
|
18
|
+
return typeof v === "string" ? v : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function numberField(r: Record<string, unknown>, key: string): number | undefined {
|
|
22
|
+
const v = r[key];
|
|
23
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The non-empty `name` values of an array of tag objects (`[{ id, name }]`), in input order. A
|
|
28
|
+
* non-array yields `[]`; an entry that is not a plain object, or whose `name` is absent, not a
|
|
29
|
+
* string, or empty, is skipped.
|
|
30
|
+
*/
|
|
31
|
+
export function namedTags(raw: unknown): string[] {
|
|
32
|
+
if (!Array.isArray(raw)) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const names: string[] = [];
|
|
36
|
+
for (const t of raw) {
|
|
37
|
+
const row = asRecord(t);
|
|
38
|
+
if (row === undefined) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const name = stringField(row, "name");
|
|
42
|
+
if (name !== undefined && name !== "") {
|
|
43
|
+
names.push(name);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return names;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** An ISO-8601 string as epoch ms, or `null` when it is not a string or does not parse. */
|
|
50
|
+
export function parseIsoMs(v: unknown): number | null {
|
|
51
|
+
return typeof v === "string" && Number.isFinite(Date.parse(v)) ? Date.parse(v) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The one item shape both mappers emit: a sync-v1 `SyncItem` with the body as a preview. Exactly
|
|
56
|
+
* these eight fields — `service` and `syncedAt` are the gateway's, never the item's.
|
|
57
|
+
*/
|
|
58
|
+
export interface ReadwiseSyncItem {
|
|
59
|
+
readonly type: "highlight" | "book";
|
|
60
|
+
readonly externalId: string;
|
|
61
|
+
readonly title: string;
|
|
62
|
+
readonly bodyPreview: string;
|
|
63
|
+
readonly url: string | null;
|
|
64
|
+
readonly canonicalUrl: string | null;
|
|
65
|
+
readonly modifiedAt: number;
|
|
66
|
+
readonly metadata: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ReadwiseMappingContext {
|
|
70
|
+
/** The walk's `now`: only the `modifiedAt` fallback for a row with no usable timestamp. */
|
|
71
|
+
readonly syncedAt: number;
|
|
72
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reserved `__nimbus_sync` tool (sync contract v1, `@nimbus-dev/sdk/sync`). Registered by the
|
|
3
|
+
* extension entry (`extension.ts`) ONLY: the standalone and gateway-bundled `server.ts` never
|
|
4
|
+
* offers it. A read tool — it only GETs Readwise — and it takes no URL: the host is fixed in
|
|
5
|
+
* `readwise-base.ts`, and the cursor only ever selects a stream and a page number.
|
|
6
|
+
*/
|
|
7
|
+
import { SYNC_TOOL_NAME, type SyncRequest } from "@nimbus-dev/sdk/sync";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import type { McpListResult } from "../../../shared/mcp-tool-kit.ts";
|
|
10
|
+
import type { ZodToolRegistrar } from "../../../shared/run-read-only-mcp-connector.ts";
|
|
11
|
+
import { type ReadwiseFetch, readwiseSyncPage } from "./sync.ts";
|
|
12
|
+
|
|
13
|
+
const SYNC_REQUEST = z.object({
|
|
14
|
+
cursor: z.string().nullable(),
|
|
15
|
+
depth: z.enum(["metadata_only", "summary", "full"]),
|
|
16
|
+
historyFloorMs: z.number().optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The parsed arguments as the SDK's `SyncRequest`. Typed both ways on purpose: `depth` must stay the
|
|
21
|
+
* SDK's `SyncDepth`, and an absent `historyFloorMs` stays absent rather than `undefined`.
|
|
22
|
+
*/
|
|
23
|
+
function toSyncRequest(args: z.infer<typeof SYNC_REQUEST>): SyncRequest {
|
|
24
|
+
return {
|
|
25
|
+
cursor: args.cursor,
|
|
26
|
+
depth: args.depth,
|
|
27
|
+
...(args.historyFloorMs === undefined ? {} : { historyFloorMs: args.historyFloorMs }),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ReadwiseSyncToolOptions {
|
|
32
|
+
/** Defaults to the global `fetch`; tests serve Readwise through it. */
|
|
33
|
+
readonly fetch?: ReadwiseFetch;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function registerReadwiseSyncTool(
|
|
37
|
+
reg: ZodToolRegistrar,
|
|
38
|
+
options: ReadwiseSyncToolOptions = {},
|
|
39
|
+
): void {
|
|
40
|
+
reg(
|
|
41
|
+
SYNC_TOOL_NAME,
|
|
42
|
+
"Nimbus gateway sync (contract v1): one page of the user's Readwise highlights, then books, per call. Reserved for the gateway.",
|
|
43
|
+
SYNC_REQUEST,
|
|
44
|
+
async (args): Promise<McpListResult> => {
|
|
45
|
+
const request = toSyncRequest(args);
|
|
46
|
+
// `depth` and `historyFloorMs` are accepted and ignored: the gateway applies depth itself,
|
|
47
|
+
// and Readwise's walk was never bounded by a history floor.
|
|
48
|
+
const response = await readwiseSyncPage(request.cursor, {
|
|
49
|
+
// Read per call, as the read tools do, so a token set after start-up is used.
|
|
50
|
+
token: process.env["READWISE_TOKEN"],
|
|
51
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
52
|
+
});
|
|
53
|
+
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
}
|