@vornrun/connector-sdk 0.7.0-beta.7 → 0.7.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/README.md +285 -2
- package/dist/check-62s2GvcO.d.ts +1137 -0
- package/dist/chunk-ZKHXHE3O.js +3511 -0
- package/dist/cli.d.ts +14 -1
- package/dist/cli.js +105 -12
- package/dist/index.d.ts +176 -412
- package/dist/index.js +98 -34
- package/package.json +4 -2
- package/dist/chunk-457KOZUU.js +0 -773
package/dist/chunk-457KOZUU.js
DELETED
|
@@ -1,773 +0,0 @@
|
|
|
1
|
-
// src/normalize.ts
|
|
2
|
-
var RESERVED_KEYS = [
|
|
3
|
-
"externalId",
|
|
4
|
-
"title",
|
|
5
|
-
"url",
|
|
6
|
-
"description",
|
|
7
|
-
"status",
|
|
8
|
-
"labels",
|
|
9
|
-
"assignee",
|
|
10
|
-
"updatedAt"
|
|
11
|
-
];
|
|
12
|
-
var UNSAFE_KEYS = ["__proto__", "constructor", "prototype"];
|
|
13
|
-
function itemExternalId(item) {
|
|
14
|
-
return String(item.externalId ?? "").trim();
|
|
15
|
-
}
|
|
16
|
-
function itemTimestamp(item, fallback) {
|
|
17
|
-
return isoTimestamp(item.updatedAt, fallback);
|
|
18
|
-
}
|
|
19
|
-
function isoTimestamp(value, fallback) {
|
|
20
|
-
if (value === void 0) return fallback;
|
|
21
|
-
const date = value instanceof Date ? value : new Date(value);
|
|
22
|
-
if (Number.isNaN(date.getTime())) {
|
|
23
|
-
throw new Error(`Invalid updatedAt: ${String(value)}`);
|
|
24
|
-
}
|
|
25
|
-
return date.toISOString();
|
|
26
|
-
}
|
|
27
|
-
function normalizeItem(item, polledAt) {
|
|
28
|
-
const externalId = itemExternalId(item);
|
|
29
|
-
if (!externalId) {
|
|
30
|
-
throw new Error("Connector item is missing externalId");
|
|
31
|
-
}
|
|
32
|
-
if (!item.title || !item.title.trim()) {
|
|
33
|
-
throw new Error(`Connector item ${externalId} is missing title`);
|
|
34
|
-
}
|
|
35
|
-
const extra = {};
|
|
36
|
-
for (const [key, value] of Object.entries(item.data ?? {})) {
|
|
37
|
-
if (RESERVED_KEYS.includes(key)) continue;
|
|
38
|
-
if (UNSAFE_KEYS.includes(key)) continue;
|
|
39
|
-
extra[key] = value;
|
|
40
|
-
}
|
|
41
|
-
return {
|
|
42
|
-
...extra,
|
|
43
|
-
externalId,
|
|
44
|
-
title: item.title,
|
|
45
|
-
url: item.url ?? "",
|
|
46
|
-
description: item.description ?? "",
|
|
47
|
-
status: item.status ?? "open",
|
|
48
|
-
labels: item.labels ?? [],
|
|
49
|
-
...item.assignee !== void 0 && { assignee: item.assignee },
|
|
50
|
-
updatedAt: isoTimestamp(item.updatedAt, polledAt)
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
function normalizeItems(items, polledAt) {
|
|
54
|
-
const seen = /* @__PURE__ */ new Set();
|
|
55
|
-
return items.map((item) => {
|
|
56
|
-
const normalized = normalizeItem(item, polledAt);
|
|
57
|
-
if (seen.has(normalized.externalId)) {
|
|
58
|
-
throw new Error(`Duplicate externalId "${normalized.externalId}" in one poll page`);
|
|
59
|
-
}
|
|
60
|
-
seen.add(normalized.externalId);
|
|
61
|
-
return normalized;
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// src/dedupe.ts
|
|
66
|
-
var MAX_BOUNDARY_IDS = 500;
|
|
67
|
-
function decodeCursor(cursor, strategy) {
|
|
68
|
-
if (!cursor) return void 0;
|
|
69
|
-
let parsed;
|
|
70
|
-
try {
|
|
71
|
-
parsed = JSON.parse(cursor);
|
|
72
|
-
} catch (error) {
|
|
73
|
-
throw new Error(`Cursor is not valid SDK cursor JSON: ${cursor}`, { cause: error });
|
|
74
|
-
}
|
|
75
|
-
const state = parsed;
|
|
76
|
-
if (!state || typeof state !== "object" || state.v !== 1 || state.s !== strategy) {
|
|
77
|
-
throw new Error(`Cursor does not belong to the "${strategy}" strategy: ${cursor}`);
|
|
78
|
-
}
|
|
79
|
-
const wellFormed = state.s === "timestamp" ? typeof state.t === "string" && Array.isArray(state.ids) && state.ids.every((id) => typeof id === "string") : typeof state.id === "string";
|
|
80
|
-
if (!wellFormed) {
|
|
81
|
-
throw new Error(`Cursor is missing the fields the "${strategy}" strategy needs: ${cursor}`);
|
|
82
|
-
}
|
|
83
|
-
return state;
|
|
84
|
-
}
|
|
85
|
-
function compare(left, right) {
|
|
86
|
-
if (left === right) return 0;
|
|
87
|
-
return left < right ? -1 : 1;
|
|
88
|
-
}
|
|
89
|
-
function page(chronological, context, hadCursor, nextCursor) {
|
|
90
|
-
const delivered = context.limit === void 0 ? chronological : chronological.slice(0, context.limit);
|
|
91
|
-
if (delivered.length === 0) {
|
|
92
|
-
return { items: [], ...context.cursor !== void 0 && { nextCursor: context.cursor } };
|
|
93
|
-
}
|
|
94
|
-
return {
|
|
95
|
-
items: delivered.map((entry) => entry.item),
|
|
96
|
-
nextCursor: JSON.stringify(nextCursor(delivered)),
|
|
97
|
-
// Only drain a backlog we know we truncated, and only once a cursor
|
|
98
|
-
// exists — a first poll should not pull the source's entire history.
|
|
99
|
-
hasMore: chronological.length > delivered.length && hadCursor
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
function timestampPoll(fetched, state, context, polledAt) {
|
|
103
|
-
const boundary = state?.t ?? context.since;
|
|
104
|
-
const seen = new Set(state?.ids ?? []);
|
|
105
|
-
const fresh = [];
|
|
106
|
-
const pinnedAlreadySeen = [];
|
|
107
|
-
for (const item of fetched) {
|
|
108
|
-
const id = itemExternalId(item);
|
|
109
|
-
const pinned = item.updatedAt === void 0 && boundary !== void 0;
|
|
110
|
-
const at = pinned ? boundary : itemTimestamp(item, polledAt);
|
|
111
|
-
const isNew = boundary === void 0 || at > boundary || at === boundary && !seen.has(id);
|
|
112
|
-
if (isNew) fresh.push({ item, at, id, ...pinned && { pinned: true } });
|
|
113
|
-
else if (pinned) pinnedAlreadySeen.push(id);
|
|
114
|
-
}
|
|
115
|
-
fresh.sort((left, right) => compare(left.at, right.at) || compare(left.id, right.id));
|
|
116
|
-
return page(fresh, context, state !== void 0, (delivered) => {
|
|
117
|
-
const newest = delivered[delivered.length - 1].at;
|
|
118
|
-
const atNewest = [];
|
|
119
|
-
for (let i = delivered.length - 1; i >= 0 && delivered[i].at === newest; i -= 1) {
|
|
120
|
-
atNewest.push(delivered[i].id);
|
|
121
|
-
}
|
|
122
|
-
const carried = newest === boundary ? [...seen, ...atNewest] : [
|
|
123
|
-
...pinnedAlreadySeen,
|
|
124
|
-
...delivered.filter((entry) => entry.pinned).map((entry) => entry.id),
|
|
125
|
-
...atNewest
|
|
126
|
-
];
|
|
127
|
-
return { v: 1, s: "timestamp", t: newest, ids: carried.slice(-MAX_BOUNDARY_IDS) };
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
function lastItemPoll(fetched, state, context, polledAt) {
|
|
131
|
-
const keyed = fetched.map((item) => ({
|
|
132
|
-
item,
|
|
133
|
-
at: itemTimestamp(item, polledAt),
|
|
134
|
-
id: itemExternalId(item)
|
|
135
|
-
}));
|
|
136
|
-
const stopAt = state ? keyed.findIndex((entry) => entry.id === state.id) : -1;
|
|
137
|
-
const chronological = (stopAt === -1 ? keyed : keyed.slice(0, stopAt)).reverse();
|
|
138
|
-
return page(chronological, context, state !== void 0, (delivered) => ({
|
|
139
|
-
v: 1,
|
|
140
|
-
s: "lastItem",
|
|
141
|
-
id: delivered[delivered.length - 1].id
|
|
142
|
-
}));
|
|
143
|
-
}
|
|
144
|
-
async function pollWithDedupe(trigger, context) {
|
|
145
|
-
const strategy = trigger.dedupe;
|
|
146
|
-
const fetchItems = trigger.fetch;
|
|
147
|
-
if (!strategy || !fetchItems) {
|
|
148
|
-
throw new Error(`Trigger ${trigger.type} is not a declarative trigger`);
|
|
149
|
-
}
|
|
150
|
-
const polledAt = context.now();
|
|
151
|
-
if (strategy === "lastItem") {
|
|
152
|
-
const state2 = decodeCursor(context.cursor, "lastItem");
|
|
153
|
-
const fetched2 = await runFetch(trigger.type, fetchItems, {
|
|
154
|
-
config: context.config,
|
|
155
|
-
...state2 && { lastItemId: state2.id },
|
|
156
|
-
...context.limit !== void 0 && { limit: context.limit },
|
|
157
|
-
now: context.now
|
|
158
|
-
});
|
|
159
|
-
return lastItemPoll(fetched2, state2, context, polledAt);
|
|
160
|
-
}
|
|
161
|
-
const state = decodeCursor(context.cursor, "timestamp");
|
|
162
|
-
const since = state?.t ?? context.since;
|
|
163
|
-
const fetched = await runFetch(trigger.type, fetchItems, {
|
|
164
|
-
config: context.config,
|
|
165
|
-
...since !== void 0 && { since },
|
|
166
|
-
...context.limit !== void 0 && { limit: context.limit },
|
|
167
|
-
now: context.now
|
|
168
|
-
});
|
|
169
|
-
return timestampPoll(fetched, state, context, polledAt);
|
|
170
|
-
}
|
|
171
|
-
async function runFetch(type, fetchItems, context) {
|
|
172
|
-
const fetched = await fetchItems(context);
|
|
173
|
-
if (!Array.isArray(fetched)) {
|
|
174
|
-
throw new Error(`Trigger ${type} fetch() did not return an array`);
|
|
175
|
-
}
|
|
176
|
-
return fetched;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// src/runtime.ts
|
|
180
|
-
var MAX_POLL_PAGES = 1e3;
|
|
181
|
-
async function runPoll(connector, triggerType, options = {}) {
|
|
182
|
-
const trigger = connector.triggers.find((entry) => entry.type === triggerType);
|
|
183
|
-
if (!trigger) {
|
|
184
|
-
throw new Error(`Connector ${connector.id} has no trigger "${triggerType}"`);
|
|
185
|
-
}
|
|
186
|
-
const now = options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
187
|
-
const polledAt = now();
|
|
188
|
-
const context = {
|
|
189
|
-
config: options.config ?? {},
|
|
190
|
-
...options.since !== void 0 && { since: options.since },
|
|
191
|
-
...options.cursor !== void 0 && { cursor: options.cursor },
|
|
192
|
-
...options.limit !== void 0 && { limit: options.limit },
|
|
193
|
-
now
|
|
194
|
-
};
|
|
195
|
-
const outcome = typeof trigger.poll === "function" ? await trigger.poll(context) : await pollWithDedupe(trigger, context);
|
|
196
|
-
if (!outcome || !Array.isArray(outcome.items)) {
|
|
197
|
-
throw new Error(`Trigger ${triggerType} did not return an items array`);
|
|
198
|
-
}
|
|
199
|
-
if (outcome.hasMore && !outcome.nextCursor) {
|
|
200
|
-
throw new Error(`Trigger ${triggerType} reported more pages without a nextCursor`);
|
|
201
|
-
}
|
|
202
|
-
return {
|
|
203
|
-
items: normalizeItems(outcome.items, polledAt),
|
|
204
|
-
...outcome.nextCursor !== void 0 && { nextCursor: outcome.nextCursor },
|
|
205
|
-
hasMore: outcome.hasMore === true
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
async function drainPoll(connector, triggerType, options = {}) {
|
|
209
|
-
const collected = [];
|
|
210
|
-
let cursor = options.cursor;
|
|
211
|
-
for (let page2 = 0; page2 < MAX_POLL_PAGES; page2++) {
|
|
212
|
-
const result = await runPoll(connector, triggerType, {
|
|
213
|
-
...options,
|
|
214
|
-
...cursor !== void 0 && { cursor }
|
|
215
|
-
});
|
|
216
|
-
collected.push(...result.items);
|
|
217
|
-
if (!result.hasMore) return collected;
|
|
218
|
-
if (result.nextCursor === cursor) {
|
|
219
|
-
throw new Error(`Trigger ${triggerType} did not advance its cursor`);
|
|
220
|
-
}
|
|
221
|
-
cursor = result.nextCursor;
|
|
222
|
-
}
|
|
223
|
-
throw new Error(`Trigger ${triggerType} exceeded ${MAX_POLL_PAGES} pages`);
|
|
224
|
-
}
|
|
225
|
-
function coerceArg(value, type) {
|
|
226
|
-
if (typeof value !== "string") return value;
|
|
227
|
-
if (type === "number") {
|
|
228
|
-
const parsed = Number(value);
|
|
229
|
-
if (Number.isNaN(parsed)) throw new Error(`Expected a number, got "${value}"`);
|
|
230
|
-
return parsed;
|
|
231
|
-
}
|
|
232
|
-
if (type === "boolean") {
|
|
233
|
-
if (value === "true") return true;
|
|
234
|
-
if (value === "false") return false;
|
|
235
|
-
throw new Error(`Expected a boolean, got "${value}"`);
|
|
236
|
-
}
|
|
237
|
-
return value;
|
|
238
|
-
}
|
|
239
|
-
async function runAction(connector, actionType, args, options = {}) {
|
|
240
|
-
const action = connector.actions.find((entry) => entry.type === actionType);
|
|
241
|
-
if (!action) {
|
|
242
|
-
throw new Error(`Connector ${connector.id} has no action "${actionType}"`);
|
|
243
|
-
}
|
|
244
|
-
const coerced = { ...args };
|
|
245
|
-
for (const input of action.inputs ?? []) {
|
|
246
|
-
const value = coerced[input.key];
|
|
247
|
-
if (value === void 0 || value === "") {
|
|
248
|
-
if (input.required) throw new Error(`Action ${actionType} requires "${input.key}"`);
|
|
249
|
-
delete coerced[input.key];
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
try {
|
|
253
|
-
coerced[input.key] = coerceArg(value, input.type);
|
|
254
|
-
} catch (error) {
|
|
255
|
-
throw new Error(
|
|
256
|
-
`Action ${actionType} argument "${input.key}": ${error instanceof Error ? error.message : String(error)}`,
|
|
257
|
-
{ cause: error }
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
const output = await action.run(coerced, {
|
|
262
|
-
config: options.config ?? {},
|
|
263
|
-
now: options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString())
|
|
264
|
-
});
|
|
265
|
-
return output ?? {};
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// src/check.ts
|
|
269
|
-
function finding(level, code, target, message) {
|
|
270
|
-
return { level, code, target, message };
|
|
271
|
-
}
|
|
272
|
-
function sampleTrigger(trigger) {
|
|
273
|
-
return { ...trigger, poll: void 0, fetch: () => trigger.sample ?? [] };
|
|
274
|
-
}
|
|
275
|
-
async function checkPollBehaviour(connector, trigger, options) {
|
|
276
|
-
const found = [];
|
|
277
|
-
const probe = { ...connector, triggers: [trigger] };
|
|
278
|
-
const target = `trigger ${trigger.type}`;
|
|
279
|
-
const now = options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
280
|
-
const attempt = async (cursor, code, what) => {
|
|
281
|
-
try {
|
|
282
|
-
return await runPoll(probe, trigger.type, {
|
|
283
|
-
config: options.config ?? {},
|
|
284
|
-
...cursor !== void 0 && { cursor },
|
|
285
|
-
now
|
|
286
|
-
});
|
|
287
|
-
} catch (error) {
|
|
288
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
289
|
-
return finding("error", code, target, `${what} threw: ${reason}`);
|
|
290
|
-
}
|
|
291
|
-
};
|
|
292
|
-
const first = await attempt(void 0, "poll-failed", "first poll");
|
|
293
|
-
if ("level" in first) return [first];
|
|
294
|
-
if (first.items.length === 0) {
|
|
295
|
-
found.push(
|
|
296
|
-
finding("warn", "no-items", target, "returned nothing, so delivery could not be verified")
|
|
297
|
-
);
|
|
298
|
-
return found;
|
|
299
|
-
}
|
|
300
|
-
if (first.nextCursor === void 0) {
|
|
301
|
-
found.push(
|
|
302
|
-
finding(
|
|
303
|
-
"error",
|
|
304
|
-
"no-cursor",
|
|
305
|
-
target,
|
|
306
|
-
"returned items but no nextCursor, so every poll will redeliver them"
|
|
307
|
-
)
|
|
308
|
-
);
|
|
309
|
-
return found;
|
|
310
|
-
}
|
|
311
|
-
const second = await attempt(
|
|
312
|
-
first.nextCursor,
|
|
313
|
-
"cursor-rejected",
|
|
314
|
-
"re-polling with its own nextCursor"
|
|
315
|
-
);
|
|
316
|
-
if ("level" in second) return [...found, second];
|
|
317
|
-
const delivered = new Set(first.items.map((item) => item.externalId));
|
|
318
|
-
const repeated = second.items.filter((item) => delivered.has(item.externalId));
|
|
319
|
-
if (repeated.length > 0) {
|
|
320
|
-
found.push(
|
|
321
|
-
finding(
|
|
322
|
-
"error",
|
|
323
|
-
"redelivers-items",
|
|
324
|
-
target,
|
|
325
|
-
`re-polling with its own nextCursor returned ${repeated.length} already-delivered item(s), starting with "${repeated[0].externalId}"`
|
|
326
|
-
)
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
|
-
if (second.hasMore && second.nextCursor === first.nextCursor) {
|
|
330
|
-
found.push(
|
|
331
|
-
finding("error", "stuck-cursor", target, "reports more pages but its cursor never advances")
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
return found;
|
|
335
|
-
}
|
|
336
|
-
async function checkConnector(connector, options = {}) {
|
|
337
|
-
const found = [];
|
|
338
|
-
if (!connector.description?.trim()) {
|
|
339
|
-
found.push(
|
|
340
|
-
finding(
|
|
341
|
-
"warn",
|
|
342
|
-
"missing-description",
|
|
343
|
-
connector.id,
|
|
344
|
-
"has no description; agents use it to decide when the connector applies"
|
|
345
|
-
)
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
const perTrigger = await Promise.all(
|
|
349
|
-
connector.triggers.map(async (trigger) => {
|
|
350
|
-
const target = `trigger ${trigger.type}`;
|
|
351
|
-
const triggerFindings = [];
|
|
352
|
-
if (!trigger.description?.trim()) {
|
|
353
|
-
triggerFindings.push(finding("warn", "missing-description", target, "has no description"));
|
|
354
|
-
}
|
|
355
|
-
if (options.live) {
|
|
356
|
-
triggerFindings.push(...await checkPollBehaviour(connector, trigger, options));
|
|
357
|
-
} else if (!trigger.sample?.length) {
|
|
358
|
-
triggerFindings.push(
|
|
359
|
-
finding(
|
|
360
|
-
"warn",
|
|
361
|
-
"unverifiable",
|
|
362
|
-
target,
|
|
363
|
-
"has no sample items and no credentials were supplied, so nothing could be verified"
|
|
364
|
-
)
|
|
365
|
-
);
|
|
366
|
-
} else if (!trigger.dedupe) {
|
|
367
|
-
triggerFindings.push(
|
|
368
|
-
finding(
|
|
369
|
-
"warn",
|
|
370
|
-
"sample-unusable",
|
|
371
|
-
target,
|
|
372
|
-
"declares sample items but implements poll() directly, so they cannot be replayed; re-run with --live"
|
|
373
|
-
)
|
|
374
|
-
);
|
|
375
|
-
} else {
|
|
376
|
-
triggerFindings.push(
|
|
377
|
-
...await checkPollBehaviour(connector, sampleTrigger(trigger), options)
|
|
378
|
-
);
|
|
379
|
-
}
|
|
380
|
-
return triggerFindings;
|
|
381
|
-
})
|
|
382
|
-
);
|
|
383
|
-
found.push(...perTrigger.flat());
|
|
384
|
-
for (const action of connector.actions) {
|
|
385
|
-
const target = `action ${action.type}`;
|
|
386
|
-
if (!action.description?.trim()) {
|
|
387
|
-
found.push(
|
|
388
|
-
finding("warn", "missing-description", target, "has no description for the agent to read")
|
|
389
|
-
);
|
|
390
|
-
}
|
|
391
|
-
if (action.idempotent === void 0) {
|
|
392
|
-
found.push(
|
|
393
|
-
finding(
|
|
394
|
-
"warn",
|
|
395
|
-
"missing-idempotent",
|
|
396
|
-
target,
|
|
397
|
-
"does not declare `idempotent`, so an agent cannot tell whether retrying is safe"
|
|
398
|
-
)
|
|
399
|
-
);
|
|
400
|
-
}
|
|
401
|
-
for (const input of action.inputs ?? []) {
|
|
402
|
-
if (!input.description?.trim()) {
|
|
403
|
-
found.push(
|
|
404
|
-
finding(
|
|
405
|
-
"warn",
|
|
406
|
-
"missing-description",
|
|
407
|
-
`${target} input ${input.key}`,
|
|
408
|
-
"has no description"
|
|
409
|
-
)
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
return found;
|
|
415
|
-
}
|
|
416
|
-
function formatFindings(findings) {
|
|
417
|
-
return findings.map((item) => `${item.level.padEnd(5)} ${item.target}: ${item.message} [${item.code}]`).join("\n");
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// src/define.ts
|
|
421
|
-
var KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
422
|
-
var PATH_DATA_PATTERN = /^[MmZzLlHhVvCcSsQqTtAa0-9\s,.\-+eE]+$/;
|
|
423
|
-
var VIEW_BOX_PATTERN = /^-?[\d.]+\s+-?[\d.]+\s+-?[\d.]+\s+-?[\d.]+$/;
|
|
424
|
-
var DEDUPE_STRATEGIES = ["timestamp", "lastItem"];
|
|
425
|
-
function assertUnique(kind, keys) {
|
|
426
|
-
const seen = /* @__PURE__ */ new Set();
|
|
427
|
-
for (const key of keys) {
|
|
428
|
-
if (seen.has(key)) throw new Error(`Duplicate ${kind} "${key}"`);
|
|
429
|
-
seen.add(key);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
function envNameFor(key, explicit) {
|
|
433
|
-
if (explicit) return explicit;
|
|
434
|
-
return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toUpperCase();
|
|
435
|
-
}
|
|
436
|
-
function defineConnector(definition) {
|
|
437
|
-
if (!KEY_PATTERN.test(definition.id ?? "")) {
|
|
438
|
-
throw new Error(`Connector id "${definition.id}" must start with a letter and be url-safe`);
|
|
439
|
-
}
|
|
440
|
-
if (!definition.name?.trim()) {
|
|
441
|
-
throw new Error(`Connector ${definition.id} is missing a name`);
|
|
442
|
-
}
|
|
443
|
-
if (definition.icon) {
|
|
444
|
-
const { viewBox, paths } = definition.icon;
|
|
445
|
-
if (!Array.isArray(paths) || paths.length === 0) {
|
|
446
|
-
throw new Error(`Connector ${definition.id} has an icon with no paths`);
|
|
447
|
-
}
|
|
448
|
-
for (const path of paths) {
|
|
449
|
-
if (typeof path !== "string" || !PATH_DATA_PATTERN.test(path)) {
|
|
450
|
-
throw new Error(
|
|
451
|
-
`Connector ${definition.id} has an icon path that is not SVG path data. Only path data is accepted, not markup.`
|
|
452
|
-
);
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
if (viewBox !== void 0 && !VIEW_BOX_PATTERN.test(viewBox)) {
|
|
456
|
-
throw new Error(`Connector ${definition.id} has an icon viewBox that is not four numbers`);
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
const triggers = definition.triggers ?? [];
|
|
460
|
-
const actions = definition.actions ?? [];
|
|
461
|
-
if (triggers.length === 0 && actions.length === 0) {
|
|
462
|
-
throw new Error(`Connector ${definition.id} declares no triggers and no actions`);
|
|
463
|
-
}
|
|
464
|
-
for (const trigger of triggers) {
|
|
465
|
-
if (!KEY_PATTERN.test(trigger.type ?? "")) {
|
|
466
|
-
throw new Error(`Trigger type "${trigger.type}" must start with a letter and be url-safe`);
|
|
467
|
-
}
|
|
468
|
-
const loose = trigger;
|
|
469
|
-
const declarative = typeof loose.fetch === "function";
|
|
470
|
-
const imperative = typeof loose.poll === "function";
|
|
471
|
-
if (declarative && imperative) {
|
|
472
|
-
throw new Error(`Trigger ${trigger.type} declares both fetch() and poll(); pick one`);
|
|
473
|
-
}
|
|
474
|
-
if (declarative !== (loose.dedupe !== void 0)) {
|
|
475
|
-
throw new Error(
|
|
476
|
-
`Trigger ${trigger.type} needs fetch() and a dedupe strategy together, not one alone`
|
|
477
|
-
);
|
|
478
|
-
}
|
|
479
|
-
if (loose.dedupe !== void 0 && !DEDUPE_STRATEGIES.includes(loose.dedupe)) {
|
|
480
|
-
throw new Error(
|
|
481
|
-
`Trigger ${trigger.type} has unknown dedupe strategy ${JSON.stringify(loose.dedupe)}; expected ${DEDUPE_STRATEGIES.join(" or ")}`
|
|
482
|
-
);
|
|
483
|
-
}
|
|
484
|
-
if (loose.poll !== void 0 && !imperative) {
|
|
485
|
-
throw new Error(`Trigger ${trigger.type} declares poll but it is not a function`);
|
|
486
|
-
}
|
|
487
|
-
if (!declarative && !imperative) {
|
|
488
|
-
throw new Error(`Trigger ${trigger.type} is missing a fetch() or poll() implementation`);
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
for (const action of actions) {
|
|
492
|
-
if (!KEY_PATTERN.test(action.type ?? "")) {
|
|
493
|
-
throw new Error(`Action type "${action.type}" must start with a letter and be url-safe`);
|
|
494
|
-
}
|
|
495
|
-
if (typeof action.run !== "function") {
|
|
496
|
-
throw new Error(`Action ${action.type} is missing a run() implementation`);
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
assertUnique(
|
|
500
|
-
"trigger",
|
|
501
|
-
triggers.map((trigger) => trigger.type)
|
|
502
|
-
);
|
|
503
|
-
assertUnique(
|
|
504
|
-
"action",
|
|
505
|
-
actions.map((action) => action.type)
|
|
506
|
-
);
|
|
507
|
-
assertUnique(
|
|
508
|
-
"config field",
|
|
509
|
-
(definition.config ?? []).map((field) => field.key)
|
|
510
|
-
);
|
|
511
|
-
return {
|
|
512
|
-
...definition,
|
|
513
|
-
version: definition.version ?? "0.0.0",
|
|
514
|
-
config: definition.config ?? [],
|
|
515
|
-
triggers,
|
|
516
|
-
actions
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
function resolveConfig(connector, env = process.env) {
|
|
520
|
-
const config = {};
|
|
521
|
-
const missing = [];
|
|
522
|
-
for (const field of connector.config) {
|
|
523
|
-
const name = envNameFor(field.key, field.env);
|
|
524
|
-
const value = env[name] ?? field.default;
|
|
525
|
-
if (value === void 0 || value === "") {
|
|
526
|
-
if (field.required) missing.push(`${field.key} (${name})`);
|
|
527
|
-
continue;
|
|
528
|
-
}
|
|
529
|
-
config[field.key] = value;
|
|
530
|
-
}
|
|
531
|
-
if (missing.length > 0) {
|
|
532
|
-
throw new Error(
|
|
533
|
-
`Connector ${connector.id} is missing required configuration: ${missing.join(", ")}`
|
|
534
|
-
);
|
|
535
|
-
}
|
|
536
|
-
return config;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// src/setup.ts
|
|
540
|
-
function pollToolName(triggerType) {
|
|
541
|
-
return `poll_${triggerType}`;
|
|
542
|
-
}
|
|
543
|
-
var MANIFEST_TOOL = "vorn_connector_manifest";
|
|
544
|
-
var PREFLIGHT_TOOL = "vorn_connector_preflight";
|
|
545
|
-
function connectionSetup(connector, triggerType) {
|
|
546
|
-
const trigger = connector.triggers.find((entry) => entry.type === triggerType);
|
|
547
|
-
if (!trigger) {
|
|
548
|
-
throw new Error(`Connector ${connector.id} has no trigger "${triggerType}"`);
|
|
549
|
-
}
|
|
550
|
-
return {
|
|
551
|
-
connectorId: connector.id,
|
|
552
|
-
triggerType,
|
|
553
|
-
filters: {
|
|
554
|
-
pollTool: pollToolName(triggerType),
|
|
555
|
-
itemsPath: "items",
|
|
556
|
-
idField: "externalId",
|
|
557
|
-
timestampField: "updatedAt",
|
|
558
|
-
titleField: "title",
|
|
559
|
-
urlField: "url",
|
|
560
|
-
cursorArg: "cursor",
|
|
561
|
-
cursorPath: "nextCursor"
|
|
562
|
-
},
|
|
563
|
-
env: connector.config.map((field) => ({
|
|
564
|
-
name: envNameFor(field.key, field.env),
|
|
565
|
-
required: field.required === true,
|
|
566
|
-
secret: field.secret === true,
|
|
567
|
-
...field.description !== void 0 && { description: field.description }
|
|
568
|
-
}))
|
|
569
|
-
};
|
|
570
|
-
}
|
|
571
|
-
function connectorManifest(connector) {
|
|
572
|
-
return {
|
|
573
|
-
id: connector.id,
|
|
574
|
-
name: connector.name,
|
|
575
|
-
version: connector.version,
|
|
576
|
-
...connector.description !== void 0 && { description: connector.description },
|
|
577
|
-
...connector.icon !== void 0 && { icon: connector.icon },
|
|
578
|
-
triggers: connector.triggers.map((trigger) => ({
|
|
579
|
-
type: trigger.type,
|
|
580
|
-
label: trigger.label,
|
|
581
|
-
...trigger.description !== void 0 && { description: trigger.description },
|
|
582
|
-
// Carried through so the app can seed a connection's status mapping and
|
|
583
|
-
// its polling workflow. Absent when the connector said nothing, which is
|
|
584
|
-
// different from saying there is nothing.
|
|
585
|
-
...trigger.statusMapping !== void 0 && { statusMapping: trigger.statusMapping },
|
|
586
|
-
...trigger.defaultWorkflow !== void 0 && { defaultWorkflow: trigger.defaultWorkflow },
|
|
587
|
-
setup: connectionSetup(connector, trigger.type)
|
|
588
|
-
})),
|
|
589
|
-
actions: connector.actions.map((action) => ({
|
|
590
|
-
type: action.type,
|
|
591
|
-
label: action.label,
|
|
592
|
-
...action.description !== void 0 && { description: action.description },
|
|
593
|
-
inputs: (action.inputs ?? []).map((input) => ({
|
|
594
|
-
key: input.key,
|
|
595
|
-
label: input.label,
|
|
596
|
-
type: input.type ?? "string",
|
|
597
|
-
required: input.required === true
|
|
598
|
-
}))
|
|
599
|
-
}))
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
// src/server.ts
|
|
604
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
605
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
606
|
-
import { z } from "zod";
|
|
607
|
-
function json(value) {
|
|
608
|
-
return {
|
|
609
|
-
// Vorn reads `structuredContent` to build step output and to find the
|
|
610
|
-
// `items` array a poll returned; the text block keeps the result readable
|
|
611
|
-
// in any generic MCP client.
|
|
612
|
-
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
613
|
-
structuredContent: value
|
|
614
|
-
};
|
|
615
|
-
}
|
|
616
|
-
function failure(error) {
|
|
617
|
-
return {
|
|
618
|
-
content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
|
|
619
|
-
isError: true
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
|
-
function inputShape(inputs) {
|
|
623
|
-
const shape = {};
|
|
624
|
-
for (const input of inputs) {
|
|
625
|
-
const base = z.string().describe(input.description ?? input.label);
|
|
626
|
-
shape[input.key] = input.required ? base : base.optional();
|
|
627
|
-
}
|
|
628
|
-
return shape;
|
|
629
|
-
}
|
|
630
|
-
function scalar(type) {
|
|
631
|
-
if (type === "number") return z.number();
|
|
632
|
-
if (type === "boolean") return z.boolean();
|
|
633
|
-
return z.string();
|
|
634
|
-
}
|
|
635
|
-
function outputSchema(outputs) {
|
|
636
|
-
const shape = {};
|
|
637
|
-
for (const output of outputs) {
|
|
638
|
-
shape[output.key] = scalar(output.type).optional().describe(output.description ?? output.key);
|
|
639
|
-
}
|
|
640
|
-
return z.looseObject(shape);
|
|
641
|
-
}
|
|
642
|
-
function createConnectorServer(connector, options = {}) {
|
|
643
|
-
const server = new McpServer(
|
|
644
|
-
{ name: connector.id, version: connector.version },
|
|
645
|
-
{ capabilities: { tools: {} } }
|
|
646
|
-
);
|
|
647
|
-
let cached = options.config;
|
|
648
|
-
const config = () => cached ??= resolveConfig(connector);
|
|
649
|
-
server.registerTool(
|
|
650
|
-
MANIFEST_TOOL,
|
|
651
|
-
{
|
|
652
|
-
description: `Describe the ${connector.name} connector and how to configure it`,
|
|
653
|
-
inputSchema: {},
|
|
654
|
-
outputSchema: z.looseObject({})
|
|
655
|
-
},
|
|
656
|
-
() => json(connectorManifest(connector))
|
|
657
|
-
);
|
|
658
|
-
if (connector.preflight) {
|
|
659
|
-
const preflight = connector.preflight.bind(connector);
|
|
660
|
-
server.registerTool(
|
|
661
|
-
PREFLIGHT_TOOL,
|
|
662
|
-
{
|
|
663
|
-
description: `Check whether ${connector.name} can run right now`,
|
|
664
|
-
inputSchema: {},
|
|
665
|
-
// Declared rather than left open like the manifest's: this shape is
|
|
666
|
-
// fixed, so a caller can validate against it. Still loose, because a
|
|
667
|
-
// connector adding a field of its own should not fail the call.
|
|
668
|
-
outputSchema: z.looseObject({
|
|
669
|
-
ok: z.boolean().describe("Whether the connector could run right now"),
|
|
670
|
-
message: z.string().optional().describe("What to do about it, when it could not")
|
|
671
|
-
})
|
|
672
|
-
},
|
|
673
|
-
async () => {
|
|
674
|
-
try {
|
|
675
|
-
return json({ ...await preflight() });
|
|
676
|
-
} catch (error) {
|
|
677
|
-
return json({
|
|
678
|
-
ok: false,
|
|
679
|
-
message: error instanceof Error ? error.message : String(error)
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
);
|
|
684
|
-
}
|
|
685
|
-
for (const trigger of connector.triggers) {
|
|
686
|
-
server.registerTool(
|
|
687
|
-
pollToolName(trigger.type),
|
|
688
|
-
{
|
|
689
|
-
description: trigger.description ?? `Poll ${connector.name} for ${trigger.label}`,
|
|
690
|
-
inputSchema: {
|
|
691
|
-
since: z.string().optional().describe("Only return items changed after this ISO timestamp"),
|
|
692
|
-
cursor: z.string().optional().describe("Opaque cursor from a previous page"),
|
|
693
|
-
limit: z.string().optional().describe("Maximum number of items to return")
|
|
694
|
-
},
|
|
695
|
-
outputSchema: z.looseObject({
|
|
696
|
-
items: z.array(z.looseObject({})).describe("Normalized items"),
|
|
697
|
-
nextCursor: z.string().optional().describe("Cursor for the next page"),
|
|
698
|
-
hasMore: z.boolean().describe("Whether another page is immediately available")
|
|
699
|
-
})
|
|
700
|
-
},
|
|
701
|
-
async (args) => {
|
|
702
|
-
try {
|
|
703
|
-
const limit = args.limit === void 0 ? void 0 : Number(args.limit);
|
|
704
|
-
if (limit !== void 0 && !Number.isFinite(limit)) {
|
|
705
|
-
throw new Error(`Invalid limit "${args.limit}"`);
|
|
706
|
-
}
|
|
707
|
-
return json(
|
|
708
|
-
await runPoll(connector, trigger.type, {
|
|
709
|
-
config: config(),
|
|
710
|
-
...args.since !== void 0 && { since: args.since },
|
|
711
|
-
...args.cursor !== void 0 && { cursor: args.cursor },
|
|
712
|
-
...limit !== void 0 && { limit },
|
|
713
|
-
...options.now && { now: options.now }
|
|
714
|
-
})
|
|
715
|
-
);
|
|
716
|
-
} catch (error) {
|
|
717
|
-
return failure(error);
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
);
|
|
721
|
-
}
|
|
722
|
-
for (const action of connector.actions) {
|
|
723
|
-
const base = action.description ?? `${action.label} in ${connector.name}`;
|
|
724
|
-
const retryHint = action.idempotent === void 0 ? "" : action.idempotent ? " Safe to retry: repeating this call with the same arguments has no additional effect." : " Not idempotent: repeating this call performs the operation again.";
|
|
725
|
-
server.registerTool(
|
|
726
|
-
action.type,
|
|
727
|
-
{
|
|
728
|
-
description: `${base}${retryHint}`,
|
|
729
|
-
inputSchema: inputShape(action.inputs ?? []),
|
|
730
|
-
outputSchema: outputSchema(action.outputs ?? [])
|
|
731
|
-
},
|
|
732
|
-
async (args) => {
|
|
733
|
-
try {
|
|
734
|
-
return json(
|
|
735
|
-
await runAction(connector, action.type, args, {
|
|
736
|
-
config: config(),
|
|
737
|
-
...options.now && { now: options.now }
|
|
738
|
-
})
|
|
739
|
-
);
|
|
740
|
-
} catch (error) {
|
|
741
|
-
return failure(error);
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
);
|
|
745
|
-
}
|
|
746
|
-
return server;
|
|
747
|
-
}
|
|
748
|
-
async function serveConnector(connector, options = {}) {
|
|
749
|
-
const server = createConnectorServer(connector, options);
|
|
750
|
-
await server.connect(new StdioServerTransport());
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
export {
|
|
754
|
-
normalizeItem,
|
|
755
|
-
normalizeItems,
|
|
756
|
-
pollWithDedupe,
|
|
757
|
-
MAX_POLL_PAGES,
|
|
758
|
-
runPoll,
|
|
759
|
-
drainPoll,
|
|
760
|
-
runAction,
|
|
761
|
-
checkConnector,
|
|
762
|
-
formatFindings,
|
|
763
|
-
envNameFor,
|
|
764
|
-
defineConnector,
|
|
765
|
-
resolveConfig,
|
|
766
|
-
pollToolName,
|
|
767
|
-
MANIFEST_TOOL,
|
|
768
|
-
PREFLIGHT_TOOL,
|
|
769
|
-
connectionSetup,
|
|
770
|
-
connectorManifest,
|
|
771
|
-
createConnectorServer,
|
|
772
|
-
serveConnector
|
|
773
|
-
};
|