@vornrun/connector-sdk 0.5.2 → 0.5.4
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 +128 -40
- package/dist/{chunk-UANUTYUV.js → chunk-W4GGTEUK.js} +407 -89
- package/dist/cli.js +37 -8
- package/dist/index.d.ts +132 -4
- package/dist/index.js +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# @vornrun/connector-sdk
|
|
2
2
|
|
|
3
3
|
Build a Vorn pull connector in TypeScript and share it as an ordinary npm
|
|
4
|
-
package.
|
|
5
|
-
|
|
6
|
-
connector already knows how to talk to one.
|
|
4
|
+
package. A connector built with this SDK runs as an MCP stdio server, and
|
|
5
|
+
Vorn's generic MCP connector already knows how to talk to one.
|
|
7
6
|
|
|
8
7
|
```bash
|
|
9
8
|
npm install @vornrun/connector-sdk
|
|
@@ -27,7 +26,10 @@ export default defineConnector({
|
|
|
27
26
|
{
|
|
28
27
|
type: 'newTicket',
|
|
29
28
|
label: 'New ticket',
|
|
30
|
-
|
|
29
|
+
description: 'Tickets created or updated since the last poll',
|
|
30
|
+
// Say how new items are recognized and the SDK owns the cursor for you.
|
|
31
|
+
dedupe: 'timestamp',
|
|
32
|
+
async fetch({ config, since, limit }) {
|
|
31
33
|
const url = new URL('/tickets', config.baseUrl)
|
|
32
34
|
if (since) url.searchParams.set('updated_after', since)
|
|
33
35
|
url.searchParams.set('per_page', String(limit ?? 100))
|
|
@@ -38,17 +40,15 @@ export default defineConnector({
|
|
|
38
40
|
if (!response.ok) throw new Error(`Acme returned ${response.status}`)
|
|
39
41
|
const tickets = (await response.json()) as AcmeTicket[]
|
|
40
42
|
|
|
41
|
-
return {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}))
|
|
51
|
-
}
|
|
43
|
+
return tickets.map((ticket) => ({
|
|
44
|
+
externalId: ticket.id,
|
|
45
|
+
title: ticket.subject,
|
|
46
|
+
url: ticket.html_url,
|
|
47
|
+
description: ticket.body,
|
|
48
|
+
status: ticket.state,
|
|
49
|
+
updatedAt: ticket.updated_at,
|
|
50
|
+
data: { priority: ticket.priority }
|
|
51
|
+
}))
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
],
|
|
@@ -101,24 +101,23 @@ export default defineConnector({
|
|
|
101
101
|
{
|
|
102
102
|
type: 'newOrder',
|
|
103
103
|
label: 'New order',
|
|
104
|
-
|
|
104
|
+
dedupe: 'timestamp',
|
|
105
|
+
async fetch({ config, since, limit }) {
|
|
105
106
|
const sql = postgres(config.databaseUrl!)
|
|
106
107
|
try {
|
|
107
108
|
const rows = await sql`
|
|
108
109
|
SELECT id, reference, status, updated_at
|
|
109
110
|
FROM orders
|
|
110
|
-
WHERE updated_at
|
|
111
|
+
WHERE updated_at >= ${since ?? '1970-01-01'}
|
|
111
112
|
ORDER BY updated_at ASC
|
|
112
113
|
LIMIT ${limit ?? 200}
|
|
113
114
|
`
|
|
114
|
-
return {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}))
|
|
121
|
-
}
|
|
115
|
+
return rows.map((row) => ({
|
|
116
|
+
externalId: row.id,
|
|
117
|
+
title: `Order ${row.reference}`,
|
|
118
|
+
status: row.status,
|
|
119
|
+
updatedAt: row.updated_at
|
|
120
|
+
}))
|
|
122
121
|
} finally {
|
|
123
122
|
await sql.end()
|
|
124
123
|
}
|
|
@@ -132,13 +131,39 @@ Two rules make a pull trigger reliable, and the SDK enforces both:
|
|
|
132
131
|
|
|
133
132
|
1. `externalId` must be stable — Vorn dedupes on it, so a changing id means
|
|
134
133
|
duplicate work items.
|
|
135
|
-
2. `updatedAt` must be monotonic and ISO-comparable —
|
|
136
|
-
|
|
134
|
+
2. `updatedAt` must be monotonic and ISO-comparable — the cursor advances from
|
|
135
|
+
it. Use `>=` when filtering on `since` and sort ascending; returning a few
|
|
136
|
+
items again is free, because the SDK drops anything already delivered.
|
|
137
|
+
|
|
138
|
+
## Dedupe strategies
|
|
139
|
+
|
|
140
|
+
`dedupe` tells the SDK how to recognize new items, and it then owns the cursor
|
|
141
|
+
— including the case that quietly breaks hand-written connectors, where several
|
|
142
|
+
items share the newest timestamp and are either dropped forever (`>`) or
|
|
143
|
+
redelivered on every poll (`>=`).
|
|
144
|
+
|
|
145
|
+
| strategy | your `fetch` receives | use it when |
|
|
146
|
+
| ----------- | --------------------- | ------------------------------------------------ |
|
|
147
|
+
| `timestamp` | `since` | the source has a reliable "last changed" field |
|
|
148
|
+
| `lastItem` | `lastItemId` | a newest-first feed with no dependable timestamp |
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
{
|
|
152
|
+
type: 'newPost',
|
|
153
|
+
label: 'New post',
|
|
154
|
+
dedupe: 'lastItem',
|
|
155
|
+
// Return the feed newest-first; the SDK stops at the last id it delivered.
|
|
156
|
+
fetch: ({ config }) => fetchFeed(config)
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
A first poll never drains the whole history — it delivers one page and starts
|
|
161
|
+
tracking from there.
|
|
137
162
|
|
|
138
|
-
## Paging a backlog
|
|
163
|
+
## Paging a backlog by hand
|
|
139
164
|
|
|
140
|
-
|
|
141
|
-
|
|
165
|
+
When a source's paging cannot be expressed as "everything since X", implement
|
|
166
|
+
`poll` instead of `fetch` and own the cursor yourself:
|
|
142
167
|
|
|
143
168
|
```ts
|
|
144
169
|
async poll({ cursor, config }) {
|
|
@@ -154,6 +179,35 @@ async poll({ cursor, config }) {
|
|
|
154
179
|
|
|
155
180
|
A cursor that does not advance is rejected rather than looped on.
|
|
156
181
|
|
|
182
|
+
## Check it before shipping
|
|
183
|
+
|
|
184
|
+
`vorn-connector check` verifies a connector against the contract Vorn relies
|
|
185
|
+
on — most importantly that re-polling with its own cursor does not redeliver
|
|
186
|
+
items it already handed over:
|
|
187
|
+
|
|
188
|
+
```console
|
|
189
|
+
$ npx vorn-connector check ./dist/index.js
|
|
190
|
+
error trigger newTicket: re-polling with its own nextCursor returned 3 already-delivered item(s), starting with "1042" [redelivers-items]
|
|
191
|
+
warn action closeTicket: does not declare `idempotent`, so an agent cannot tell whether retrying is safe [missing-idempotent]
|
|
192
|
+
|
|
193
|
+
1 error(s), 1 warning(s)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
It exits non-zero on errors, so it works as a CI gate. Add `sample` items to a
|
|
197
|
+
trigger and they are replayed through the real dedupe pipeline, so a connector
|
|
198
|
+
can be checked before anyone has credentials for it; pass `--live` to poll the
|
|
199
|
+
real source instead.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
{
|
|
203
|
+
type: 'newTicket',
|
|
204
|
+
label: 'New ticket',
|
|
205
|
+
dedupe: 'timestamp',
|
|
206
|
+
sample: [{ externalId: '1', title: 'Example ticket', updatedAt: '2026-01-01T00:00:00.000Z' }],
|
|
207
|
+
fetch: ({ config, since }) => fetchTickets(config, since)
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
157
211
|
## Test it without running the app
|
|
158
212
|
|
|
159
213
|
```ts
|
|
@@ -181,23 +235,53 @@ test('does not redeliver the same backlog forever', async () => {
|
|
|
181
235
|
|
|
182
236
|
## Install it in Vorn
|
|
183
237
|
|
|
184
|
-
|
|
238
|
+
**Settings → Connectors → MCP → From a package**, then type the package name.
|
|
239
|
+
|
|
240
|
+
Vorn starts the connector once, asks it to describe itself, and fills in the
|
|
241
|
+
connection settings from the answer. All that is left on screen is the
|
|
242
|
+
connector's own name, its triggers, and the config it declared.
|
|
243
|
+
|
|
244
|
+
Nothing needs transcribing: `pollTool`, `itemsPath`, `idField`,
|
|
245
|
+
`timestampField`, `titleField`, `urlField`, `cursorArg` and `cursorPath` all
|
|
246
|
+
come from the manifest. `cursorArg` is what makes the dedupe strategy
|
|
247
|
+
load-bearing — Vorn hands the connector back its own cursor on every poll and
|
|
248
|
+
fires for whatever it returns, rather than re-filtering by timestamp itself.
|
|
249
|
+
|
|
250
|
+
Because the connector is a normal npm package, versions are pinned by the
|
|
251
|
+
package spec and upgrades are a version bump — no separate registry.
|
|
252
|
+
|
|
253
|
+
To see the same values on the command line, or to wire a connection up by
|
|
254
|
+
hand:
|
|
185
255
|
|
|
186
256
|
```bash
|
|
187
257
|
npx vorn-connector setup ./dist/index.js
|
|
188
258
|
```
|
|
189
259
|
|
|
190
|
-
|
|
260
|
+
### Ship an icon
|
|
191
261
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
| Command | `npx` |
|
|
195
|
-
| Arguments | `["-y", "@your-scope/acme-connector"]` |
|
|
196
|
-
| Secret env | `{"API_TOKEN": "…"}` |
|
|
197
|
-
| Filters | `pollTool: poll_newTicket`, `itemsPath: items`, `idField: externalId`, `timestampField: updatedAt`, `titleField: title`, `urlField: url` |
|
|
262
|
+
Without one, a connector shows the generic MCP glyph and is hard to pick out
|
|
263
|
+
of a list of connections.
|
|
198
264
|
|
|
199
|
-
|
|
200
|
-
|
|
265
|
+
```ts
|
|
266
|
+
defineConnector({
|
|
267
|
+
id: 'acme',
|
|
268
|
+
name: 'Acme',
|
|
269
|
+
icon: {
|
|
270
|
+
viewBox: '0 0 24 24',
|
|
271
|
+
paths: ['M12 2 2 22h20L12 2z']
|
|
272
|
+
}
|
|
273
|
+
// ...
|
|
274
|
+
})
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Path data only — no markup, no `<svg>` wrapper, no external references. Vorn
|
|
278
|
+
draws these as `<path d="...">` inside an element it owns, so an icon can
|
|
279
|
+
never contribute markup to the app rendering it. Anything that is not path
|
|
280
|
+
data is rejected by `defineConnector` at import time, and again when Vorn
|
|
281
|
+
reads the manifest.
|
|
282
|
+
|
|
283
|
+
Paths are filled with `currentColor`, so the icon picks up the surrounding
|
|
284
|
+
text color instead of fighting the theme.
|
|
201
285
|
|
|
202
286
|
## CLI
|
|
203
287
|
|
|
@@ -205,9 +289,13 @@ Because the connector is a normal npm package, versions are pinned by the
|
|
|
205
289
|
vorn-connector manifest <module> Print the manifest as JSON
|
|
206
290
|
vorn-connector setup <module> [trigger] Print the Vorn connection settings
|
|
207
291
|
vorn-connector poll <module> <trigger> Run one poll against the environment
|
|
292
|
+
vorn-connector check <module> Verify the connector against the contract
|
|
208
293
|
vorn-connector serve <module> Serve on stdio (what Vorn runs)
|
|
209
294
|
```
|
|
210
295
|
|
|
211
296
|
`poll` accepts `--since <iso>` and `--limit <n>`, and reads the connector's
|
|
212
297
|
declared config from your shell environment — the fastest way to confirm
|
|
213
298
|
credentials and field mapping before wiring anything up.
|
|
299
|
+
|
|
300
|
+
`check` runs against declared `sample` data by default and takes `--live` to
|
|
301
|
+
poll the real source instead.
|
|
@@ -1,84 +1,3 @@
|
|
|
1
|
-
// src/define.ts
|
|
2
|
-
var KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
3
|
-
function assertUnique(kind, keys) {
|
|
4
|
-
const seen = /* @__PURE__ */ new Set();
|
|
5
|
-
for (const key of keys) {
|
|
6
|
-
if (seen.has(key)) throw new Error(`Duplicate ${kind} "${key}"`);
|
|
7
|
-
seen.add(key);
|
|
8
|
-
}
|
|
9
|
-
}
|
|
10
|
-
function envNameFor(key, explicit) {
|
|
11
|
-
if (explicit) return explicit;
|
|
12
|
-
return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toUpperCase();
|
|
13
|
-
}
|
|
14
|
-
function defineConnector(definition) {
|
|
15
|
-
if (!KEY_PATTERN.test(definition.id ?? "")) {
|
|
16
|
-
throw new Error(`Connector id "${definition.id}" must start with a letter and be url-safe`);
|
|
17
|
-
}
|
|
18
|
-
if (!definition.name?.trim()) {
|
|
19
|
-
throw new Error(`Connector ${definition.id} is missing a name`);
|
|
20
|
-
}
|
|
21
|
-
const triggers = definition.triggers ?? [];
|
|
22
|
-
const actions = definition.actions ?? [];
|
|
23
|
-
if (triggers.length === 0 && actions.length === 0) {
|
|
24
|
-
throw new Error(`Connector ${definition.id} declares no triggers and no actions`);
|
|
25
|
-
}
|
|
26
|
-
for (const trigger of triggers) {
|
|
27
|
-
if (!KEY_PATTERN.test(trigger.type ?? "")) {
|
|
28
|
-
throw new Error(`Trigger type "${trigger.type}" must start with a letter and be url-safe`);
|
|
29
|
-
}
|
|
30
|
-
if (typeof trigger.poll !== "function") {
|
|
31
|
-
throw new Error(`Trigger ${trigger.type} is missing a poll() implementation`);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
for (const action of actions) {
|
|
35
|
-
if (!KEY_PATTERN.test(action.type ?? "")) {
|
|
36
|
-
throw new Error(`Action type "${action.type}" must start with a letter and be url-safe`);
|
|
37
|
-
}
|
|
38
|
-
if (typeof action.run !== "function") {
|
|
39
|
-
throw new Error(`Action ${action.type} is missing a run() implementation`);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
assertUnique(
|
|
43
|
-
"trigger",
|
|
44
|
-
triggers.map((trigger) => trigger.type)
|
|
45
|
-
);
|
|
46
|
-
assertUnique(
|
|
47
|
-
"action",
|
|
48
|
-
actions.map((action) => action.type)
|
|
49
|
-
);
|
|
50
|
-
assertUnique(
|
|
51
|
-
"config field",
|
|
52
|
-
(definition.config ?? []).map((field) => field.key)
|
|
53
|
-
);
|
|
54
|
-
return {
|
|
55
|
-
...definition,
|
|
56
|
-
version: definition.version ?? "0.0.0",
|
|
57
|
-
config: definition.config ?? [],
|
|
58
|
-
triggers,
|
|
59
|
-
actions
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
function resolveConfig(connector, env = process.env) {
|
|
63
|
-
const config = {};
|
|
64
|
-
const missing = [];
|
|
65
|
-
for (const field of connector.config) {
|
|
66
|
-
const name = envNameFor(field.key, field.env);
|
|
67
|
-
const value = env[name] ?? field.default;
|
|
68
|
-
if (value === void 0 || value === "") {
|
|
69
|
-
if (field.required) missing.push(`${field.key} (${name})`);
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
config[field.key] = value;
|
|
73
|
-
}
|
|
74
|
-
if (missing.length > 0) {
|
|
75
|
-
throw new Error(
|
|
76
|
-
`Connector ${connector.id} is missing required configuration: ${missing.join(", ")}`
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
return config;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
1
|
// src/normalize.ts
|
|
83
2
|
var RESERVED_KEYS = [
|
|
84
3
|
"externalId",
|
|
@@ -91,6 +10,12 @@ var RESERVED_KEYS = [
|
|
|
91
10
|
"updatedAt"
|
|
92
11
|
];
|
|
93
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
|
+
}
|
|
94
19
|
function isoTimestamp(value, fallback) {
|
|
95
20
|
if (value === void 0) return fallback;
|
|
96
21
|
const date = value instanceof Date ? value : new Date(value);
|
|
@@ -100,7 +25,7 @@ function isoTimestamp(value, fallback) {
|
|
|
100
25
|
return date.toISOString();
|
|
101
26
|
}
|
|
102
27
|
function normalizeItem(item, polledAt) {
|
|
103
|
-
const externalId =
|
|
28
|
+
const externalId = itemExternalId(item);
|
|
104
29
|
if (!externalId) {
|
|
105
30
|
throw new Error("Connector item is missing externalId");
|
|
106
31
|
}
|
|
@@ -137,6 +62,120 @@ function normalizeItems(items, polledAt) {
|
|
|
137
62
|
});
|
|
138
63
|
}
|
|
139
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
|
+
|
|
140
179
|
// src/runtime.ts
|
|
141
180
|
var MAX_POLL_PAGES = 1e3;
|
|
142
181
|
async function runPoll(connector, triggerType, options = {}) {
|
|
@@ -153,7 +192,7 @@ async function runPoll(connector, triggerType, options = {}) {
|
|
|
153
192
|
...options.limit !== void 0 && { limit: options.limit },
|
|
154
193
|
now
|
|
155
194
|
};
|
|
156
|
-
const outcome = await trigger.poll(context);
|
|
195
|
+
const outcome = typeof trigger.poll === "function" ? await trigger.poll(context) : await pollWithDedupe(trigger, context);
|
|
157
196
|
if (!outcome || !Array.isArray(outcome.items)) {
|
|
158
197
|
throw new Error(`Trigger ${triggerType} did not return an items array`);
|
|
159
198
|
}
|
|
@@ -169,7 +208,7 @@ async function runPoll(connector, triggerType, options = {}) {
|
|
|
169
208
|
async function drainPoll(connector, triggerType, options = {}) {
|
|
170
209
|
const collected = [];
|
|
171
210
|
let cursor = options.cursor;
|
|
172
|
-
for (let
|
|
211
|
+
for (let page2 = 0; page2 < MAX_POLL_PAGES; page2++) {
|
|
173
212
|
const result = await runPoll(connector, triggerType, {
|
|
174
213
|
...options,
|
|
175
214
|
...cursor !== void 0 && { cursor }
|
|
@@ -226,6 +265,277 @@ async function runAction(connector, actionType, args, options = {}) {
|
|
|
226
265
|
return output ?? {};
|
|
227
266
|
}
|
|
228
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
|
+
|
|
229
539
|
// src/setup.ts
|
|
230
540
|
function pollToolName(triggerType) {
|
|
231
541
|
return `poll_${triggerType}`;
|
|
@@ -245,7 +555,9 @@ function connectionSetup(connector, triggerType) {
|
|
|
245
555
|
idField: "externalId",
|
|
246
556
|
timestampField: "updatedAt",
|
|
247
557
|
titleField: "title",
|
|
248
|
-
urlField: "url"
|
|
558
|
+
urlField: "url",
|
|
559
|
+
cursorArg: "cursor",
|
|
560
|
+
cursorPath: "nextCursor"
|
|
249
561
|
},
|
|
250
562
|
env: connector.config.map((field) => ({
|
|
251
563
|
name: envNameFor(field.key, field.env),
|
|
@@ -261,6 +573,7 @@ function connectorManifest(connector) {
|
|
|
261
573
|
name: connector.name,
|
|
262
574
|
version: connector.version,
|
|
263
575
|
...connector.description !== void 0 && { description: connector.description },
|
|
576
|
+
...connector.icon !== void 0 && { icon: connector.icon },
|
|
264
577
|
triggers: connector.triggers.map((trigger) => ({
|
|
265
578
|
type: trigger.type,
|
|
266
579
|
label: trigger.label,
|
|
@@ -374,10 +687,12 @@ function createConnectorServer(connector, options = {}) {
|
|
|
374
687
|
);
|
|
375
688
|
}
|
|
376
689
|
for (const action of connector.actions) {
|
|
690
|
+
const base = action.description ?? `${action.label} in ${connector.name}`;
|
|
691
|
+
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.";
|
|
377
692
|
server.registerTool(
|
|
378
693
|
action.type,
|
|
379
694
|
{
|
|
380
|
-
description:
|
|
695
|
+
description: `${base}${retryHint}`,
|
|
381
696
|
inputSchema: inputShape(action.inputs ?? []),
|
|
382
697
|
outputSchema: outputSchema(action.outputs ?? [])
|
|
383
698
|
},
|
|
@@ -403,15 +718,18 @@ async function serveConnector(connector, options = {}) {
|
|
|
403
718
|
}
|
|
404
719
|
|
|
405
720
|
export {
|
|
406
|
-
envNameFor,
|
|
407
|
-
defineConnector,
|
|
408
|
-
resolveConfig,
|
|
409
721
|
normalizeItem,
|
|
410
722
|
normalizeItems,
|
|
723
|
+
pollWithDedupe,
|
|
411
724
|
MAX_POLL_PAGES,
|
|
412
725
|
runPoll,
|
|
413
726
|
drainPoll,
|
|
414
727
|
runAction,
|
|
728
|
+
checkConnector,
|
|
729
|
+
formatFindings,
|
|
730
|
+
envNameFor,
|
|
731
|
+
defineConnector,
|
|
732
|
+
resolveConfig,
|
|
415
733
|
pollToolName,
|
|
416
734
|
MANIFEST_TOOL,
|
|
417
735
|
connectionSetup,
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
checkConnector,
|
|
3
4
|
connectionSetup,
|
|
4
5
|
connectorManifest,
|
|
6
|
+
formatFindings,
|
|
5
7
|
resolveConfig,
|
|
6
8
|
runPoll,
|
|
7
9
|
serveConnector
|
|
8
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-W4GGTEUK.js";
|
|
9
11
|
|
|
10
12
|
// src/cli.ts
|
|
11
13
|
import { pathToFileURL } from "url";
|
|
@@ -15,25 +17,37 @@ var USAGE = `vorn-connector <command> <module> [options]
|
|
|
15
17
|
Commands:
|
|
16
18
|
manifest <module> Print the connector manifest as JSON
|
|
17
19
|
setup <module> [trigger] Print the Vorn connection settings to paste
|
|
20
|
+
check <module> Verify the connector against Vorn's contract
|
|
18
21
|
poll <module> <trigger> Run one poll against the current environment
|
|
19
22
|
serve <module> Serve the connector on stdio (what Vorn runs)
|
|
20
23
|
|
|
21
24
|
Options:
|
|
22
25
|
--since <iso> Lower bound passed to poll
|
|
23
|
-
--limit <n> Maximum items to request
|
|
24
|
-
|
|
26
|
+
--limit <n> Maximum items to request
|
|
27
|
+
--live Let check poll for real using the environment`;
|
|
28
|
+
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["live"]);
|
|
29
|
+
function parseArgs(args) {
|
|
25
30
|
const flags = {};
|
|
31
|
+
const positional = [];
|
|
26
32
|
for (let index = 0; index < args.length; index++) {
|
|
27
33
|
const arg = args[index];
|
|
28
|
-
if (!arg.startsWith("--"))
|
|
34
|
+
if (!arg.startsWith("--")) {
|
|
35
|
+
positional.push(arg);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const name = arg.slice(2);
|
|
39
|
+
if (BOOLEAN_FLAGS.has(name)) {
|
|
40
|
+
flags[name] = "true";
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
29
43
|
const value = args[index + 1];
|
|
30
44
|
if (value === void 0 || value.startsWith("--")) {
|
|
31
45
|
throw new Error(`Missing value for ${arg}`);
|
|
32
46
|
}
|
|
33
|
-
flags[
|
|
47
|
+
flags[name] = value;
|
|
34
48
|
index++;
|
|
35
49
|
}
|
|
36
|
-
return flags;
|
|
50
|
+
return { flags, positional };
|
|
37
51
|
}
|
|
38
52
|
function pickConnector(loaded, modulePath) {
|
|
39
53
|
const module = loaded;
|
|
@@ -59,8 +73,7 @@ ${USAGE}`);
|
|
|
59
73
|
return 1;
|
|
60
74
|
}
|
|
61
75
|
const connector = pickConnector(await deps.load(modulePath), modulePath);
|
|
62
|
-
const positional = rest
|
|
63
|
-
const flags = parseFlags(rest);
|
|
76
|
+
const { flags, positional } = parseArgs(rest);
|
|
64
77
|
switch (command) {
|
|
65
78
|
case "manifest":
|
|
66
79
|
deps.write(JSON.stringify(connectorManifest(connector), null, 2));
|
|
@@ -81,6 +94,22 @@ ${USAGE}`);
|
|
|
81
94
|
}
|
|
82
95
|
return 0;
|
|
83
96
|
}
|
|
97
|
+
case "check": {
|
|
98
|
+
const findings = await checkConnector(connector, {
|
|
99
|
+
...flags.live === "true" && {
|
|
100
|
+
live: true,
|
|
101
|
+
config: resolveConfig(connector, deps.env ?? process.env)
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
const errors = findings.filter((item) => item.level === "error");
|
|
105
|
+
if (findings.length > 0) deps.write(formatFindings(findings));
|
|
106
|
+
deps.write(
|
|
107
|
+
errors.length > 0 ? `
|
|
108
|
+
${errors.length} error(s), ${findings.length - errors.length} warning(s)` : `
|
|
109
|
+
${connector.id} passed with ${findings.length} warning(s)`
|
|
110
|
+
);
|
|
111
|
+
return errors.length > 0 ? 1 : 0;
|
|
112
|
+
}
|
|
84
113
|
case "poll": {
|
|
85
114
|
const triggerType = positional[0];
|
|
86
115
|
if (!triggerType) {
|
package/dist/index.d.ts
CHANGED
|
@@ -72,13 +72,74 @@ interface PollOutcome {
|
|
|
72
72
|
nextCursor?: string;
|
|
73
73
|
hasMore?: boolean;
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
/**
|
|
76
|
+
* How the SDK decides which fetched items are new.
|
|
77
|
+
*
|
|
78
|
+
* - `timestamp` — for sources that expose a reliable "last changed" field and
|
|
79
|
+
* can filter on it. Handles the boundary case where several items share the
|
|
80
|
+
* newest timestamp, which is the classic source of both duplicates and
|
|
81
|
+
* silently dropped items.
|
|
82
|
+
* - `lastItem` — for feeds that return newest-first with no dependable
|
|
83
|
+
* timestamp. The cursor is the newest id already delivered.
|
|
84
|
+
*/
|
|
85
|
+
type DedupeStrategy = 'timestamp' | 'lastItem';
|
|
86
|
+
/**
|
|
87
|
+
* What a declarative trigger's `fetch` receives. Deliberately smaller than
|
|
88
|
+
* {@link PollContext}: cursor encoding, ordering, windowing and de-duplication
|
|
89
|
+
* are the SDK's job, so the author only has to answer "what is there now?".
|
|
90
|
+
*/
|
|
91
|
+
interface FetchContext {
|
|
92
|
+
config: ConnectorConfig;
|
|
93
|
+
/**
|
|
94
|
+
* With `dedupe: 'timestamp'`, everything changed at or after this instant is
|
|
95
|
+
* worth returning. Absent on the very first poll. Returning a little too
|
|
96
|
+
* much is safe — the SDK drops what was already delivered.
|
|
97
|
+
*/
|
|
98
|
+
since?: string;
|
|
99
|
+
/**
|
|
100
|
+
* With `dedupe: 'lastItem'`, the newest id already delivered. Absent on the
|
|
101
|
+
* very first poll. Return the feed newest-first and the SDK will stop there.
|
|
102
|
+
*/
|
|
103
|
+
lastItemId?: string;
|
|
104
|
+
/** Upper bound on items worth returning in one call. */
|
|
105
|
+
limit?: number;
|
|
106
|
+
/** Injectable clock so tests are deterministic. */
|
|
107
|
+
now(): string;
|
|
108
|
+
}
|
|
109
|
+
interface TriggerBase {
|
|
76
110
|
/** Event key, e.g. `workItemCreated`. Becomes the `poll_<type>` MCP tool. */
|
|
77
111
|
type: string;
|
|
78
112
|
label: string;
|
|
79
113
|
description?: string;
|
|
80
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Representative items. `vorn-connector check` replays these through the
|
|
116
|
+
* real dedupe pipeline, so a connector can be verified before anyone has
|
|
117
|
+
* credentials for it.
|
|
118
|
+
*/
|
|
119
|
+
sample?: ConnectorItem[];
|
|
81
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* A trigger is either declarative or hand-written, never both — expressed as a
|
|
123
|
+
* union so the invalid combinations are a type error at authoring time rather
|
|
124
|
+
* than a throw when the connector is first loaded.
|
|
125
|
+
*/
|
|
126
|
+
type TriggerDefinition = TriggerBase & ({
|
|
127
|
+
/**
|
|
128
|
+
* Declarative polling: return what the source has and let the SDK
|
|
129
|
+
* handle cursors and de-duplication.
|
|
130
|
+
*/
|
|
131
|
+
dedupe: DedupeStrategy;
|
|
132
|
+
fetch(context: FetchContext): Promise<ConnectorItem[]> | ConnectorItem[];
|
|
133
|
+
poll?: never;
|
|
134
|
+
} | {
|
|
135
|
+
/**
|
|
136
|
+
* Full control over cursors and paging. Use only when the source's
|
|
137
|
+
* paging cannot be expressed as "give me everything since X".
|
|
138
|
+
*/
|
|
139
|
+
poll(context: PollContext): Promise<PollOutcome> | PollOutcome;
|
|
140
|
+
dedupe?: never;
|
|
141
|
+
fetch?: never;
|
|
142
|
+
});
|
|
82
143
|
interface ActionInputField {
|
|
83
144
|
key: string;
|
|
84
145
|
label: string;
|
|
@@ -105,16 +166,37 @@ interface ActionDefinition {
|
|
|
105
166
|
type: string;
|
|
106
167
|
label: string;
|
|
107
168
|
description?: string;
|
|
169
|
+
/**
|
|
170
|
+
* Whether repeating the call with the same arguments is safe. Surfaced in
|
|
171
|
+
* the MCP tool description, because an agent retrying a failed step has no
|
|
172
|
+
* other way to know whether it is about to create a second issue.
|
|
173
|
+
*/
|
|
174
|
+
idempotent?: boolean;
|
|
108
175
|
inputs?: ActionInputField[];
|
|
109
176
|
outputs?: ActionOutputField[];
|
|
110
177
|
run(args: Record<string, unknown>, context: ActionContext): Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
|
|
111
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* A connector's own glyph, so an installed connector is recognizable in a list
|
|
181
|
+
* rather than sharing one generic icon with every other one.
|
|
182
|
+
*
|
|
183
|
+
* Path data only — deliberately not markup. Vorn draws these itself as
|
|
184
|
+
* `<path d="...">` inside an `<svg>` it owns, so a connector cannot inject
|
|
185
|
+
* elements, scripts or external references into the app rendering it.
|
|
186
|
+
*/
|
|
187
|
+
interface ConnectorIcon {
|
|
188
|
+
/** Defaults to `0 0 24 24`. */
|
|
189
|
+
viewBox?: string;
|
|
190
|
+
/** SVG path `d` data, drawn with `fill="currentColor"` so it inherits color. */
|
|
191
|
+
paths: string[];
|
|
192
|
+
}
|
|
112
193
|
interface ConnectorDefinition {
|
|
113
194
|
/** Stable connector id, e.g. `azure-devops`. */
|
|
114
195
|
id: string;
|
|
115
196
|
name: string;
|
|
116
197
|
version?: string;
|
|
117
198
|
description?: string;
|
|
199
|
+
icon?: ConnectorIcon;
|
|
118
200
|
config?: ConnectorConfigField[];
|
|
119
201
|
triggers?: TriggerDefinition[];
|
|
120
202
|
actions?: ActionDefinition[];
|
|
@@ -145,6 +227,47 @@ declare function defineConnector(definition: ConnectorDefinition): Connector;
|
|
|
145
227
|
*/
|
|
146
228
|
declare function resolveConfig(connector: Connector, env?: NodeJS.ProcessEnv): ConnectorConfig;
|
|
147
229
|
|
|
230
|
+
interface CheckFinding {
|
|
231
|
+
/** `error` means the connector will misbehave in Vorn; `warn` is advisory. */
|
|
232
|
+
level: 'error' | 'warn';
|
|
233
|
+
code: string;
|
|
234
|
+
/** Which part of the connector the finding is about. */
|
|
235
|
+
target: string;
|
|
236
|
+
message: string;
|
|
237
|
+
}
|
|
238
|
+
interface CheckOptions {
|
|
239
|
+
/**
|
|
240
|
+
* Poll every trigger against the real source. Off by default, so a check
|
|
241
|
+
* runs on declared `sample` items and the definition alone.
|
|
242
|
+
*/
|
|
243
|
+
live?: boolean;
|
|
244
|
+
/** Credentials, required by `live`. */
|
|
245
|
+
config?: ConnectorConfig;
|
|
246
|
+
now?: () => string;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Check a connector against the contract Vorn relies on.
|
|
250
|
+
*
|
|
251
|
+
* The point is a feedback loop: a connector — hand-written or generated — can
|
|
252
|
+
* be verified before it is ever installed, catching the failures that are
|
|
253
|
+
* otherwise invisible until duplicate tasks show up in someone's inbox days
|
|
254
|
+
* later.
|
|
255
|
+
*/
|
|
256
|
+
declare function checkConnector(connector: Connector, options?: CheckOptions): Promise<CheckFinding[]>;
|
|
257
|
+
/** Render findings for a terminal. Returns an empty string when all clear. */
|
|
258
|
+
declare function formatFindings(findings: CheckFinding[]): string;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Run a declarative trigger: call the author's `fetch`, then apply the chosen
|
|
262
|
+
* dedupe strategy.
|
|
263
|
+
*
|
|
264
|
+
* This exists because cursor bookkeeping is where hand-written pull connectors
|
|
265
|
+
* go wrong — duplicate deliveries, items lost at the timestamp boundary, and
|
|
266
|
+
* cursors that never advance. Solving it once here means every connector, and
|
|
267
|
+
* every connector an agent generates, inherits the fix.
|
|
268
|
+
*/
|
|
269
|
+
declare function pollWithDedupe(trigger: TriggerDefinition, context: PollContext): Promise<PollOutcome>;
|
|
270
|
+
|
|
148
271
|
/**
|
|
149
272
|
* Turn an author-supplied item into the flat JSON shape Vorn consumes.
|
|
150
273
|
*
|
|
@@ -211,6 +334,8 @@ interface ConnectionSetup {
|
|
|
211
334
|
timestampField: 'updatedAt';
|
|
212
335
|
titleField: 'title';
|
|
213
336
|
urlField: 'url';
|
|
337
|
+
cursorArg: 'cursor';
|
|
338
|
+
cursorPath: 'nextCursor';
|
|
214
339
|
};
|
|
215
340
|
/** Environment variable names the connector reads. */
|
|
216
341
|
env: Array<{
|
|
@@ -225,7 +350,9 @@ interface ConnectionSetup {
|
|
|
225
350
|
*
|
|
226
351
|
* Every SDK connector normalizes to the same field names, so this mapping is
|
|
227
352
|
* fixed; it is generated rather than documented so a rename in the SDK cannot
|
|
228
|
-
* drift away from the setup instructions users copy.
|
|
353
|
+
* drift away from the setup instructions users copy. `cursorArg` hands the
|
|
354
|
+
* connector back its own cursor each poll, which is what lets its dedupe
|
|
355
|
+
* strategy — rather than Vorn's timestamp comparison — decide what is new.
|
|
229
356
|
*/
|
|
230
357
|
declare function connectionSetup(connector: Connector, triggerType: string): ConnectionSetup;
|
|
231
358
|
interface ConnectorManifest {
|
|
@@ -233,6 +360,7 @@ interface ConnectorManifest {
|
|
|
233
360
|
name: string;
|
|
234
361
|
version: string;
|
|
235
362
|
description?: string;
|
|
363
|
+
icon?: ConnectorIcon;
|
|
236
364
|
triggers: Array<{
|
|
237
365
|
type: string;
|
|
238
366
|
label: string;
|
|
@@ -295,4 +423,4 @@ interface ConnectorHarness {
|
|
|
295
423
|
*/
|
|
296
424
|
declare function createConnectorHarness(connector: Connector, harnessOptions?: HarnessOptions): ConnectorHarness;
|
|
297
425
|
|
|
298
|
-
export { type ActionContext, type ActionDefinition, type ActionInputField, type ConnectionSetup, type Connector, type ConnectorConfig, type ConnectorConfigField, type ConnectorDefinition, type ConnectorHarness, type ConnectorItem, type ConnectorManifest, type ConnectorServerOptions, type HarnessOptions, MANIFEST_TOOL, MAX_POLL_PAGES, type NormalizedItem, type PollContext, type PollOutcome, type PollPage, type RunActionOptions, type RunPollOptions, type TriggerDefinition, connectionSetup, connectorManifest, createConnectorHarness, createConnectorServer, defineConnector, drainPoll, envNameFor, normalizeItem, normalizeItems, pollToolName, resolveConfig, runAction, runPoll, serveConnector };
|
|
426
|
+
export { type ActionContext, type ActionDefinition, type ActionInputField, type CheckFinding, type CheckOptions, type ConnectionSetup, type Connector, type ConnectorConfig, type ConnectorConfigField, type ConnectorDefinition, type ConnectorHarness, type ConnectorIcon, type ConnectorItem, type ConnectorManifest, type ConnectorServerOptions, type DedupeStrategy, type FetchContext, type HarnessOptions, MANIFEST_TOOL, MAX_POLL_PAGES, type NormalizedItem, type PollContext, type PollOutcome, type PollPage, type RunActionOptions, type RunPollOptions, type TriggerDefinition, checkConnector, connectionSetup, connectorManifest, createConnectorHarness, createConnectorServer, defineConnector, drainPoll, envNameFor, formatFindings, normalizeItem, normalizeItems, pollToolName, pollWithDedupe, resolveConfig, runAction, runPoll, serveConnector };
|
package/dist/index.js
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import {
|
|
2
2
|
MANIFEST_TOOL,
|
|
3
3
|
MAX_POLL_PAGES,
|
|
4
|
+
checkConnector,
|
|
4
5
|
connectionSetup,
|
|
5
6
|
connectorManifest,
|
|
6
7
|
createConnectorServer,
|
|
7
8
|
defineConnector,
|
|
8
9
|
drainPoll,
|
|
9
10
|
envNameFor,
|
|
11
|
+
formatFindings,
|
|
10
12
|
normalizeItem,
|
|
11
13
|
normalizeItems,
|
|
12
14
|
pollToolName,
|
|
15
|
+
pollWithDedupe,
|
|
13
16
|
resolveConfig,
|
|
14
17
|
runAction,
|
|
15
18
|
runPoll,
|
|
16
19
|
serveConnector
|
|
17
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-W4GGTEUK.js";
|
|
18
21
|
|
|
19
22
|
// src/harness.ts
|
|
20
23
|
function createConnectorHarness(connector, harnessOptions = {}) {
|
|
@@ -49,6 +52,7 @@ function createConnectorHarness(connector, harnessOptions = {}) {
|
|
|
49
52
|
export {
|
|
50
53
|
MANIFEST_TOOL,
|
|
51
54
|
MAX_POLL_PAGES,
|
|
55
|
+
checkConnector,
|
|
52
56
|
connectionSetup,
|
|
53
57
|
connectorManifest,
|
|
54
58
|
createConnectorHarness,
|
|
@@ -56,9 +60,11 @@ export {
|
|
|
56
60
|
defineConnector,
|
|
57
61
|
drainPoll,
|
|
58
62
|
envNameFor,
|
|
63
|
+
formatFindings,
|
|
59
64
|
normalizeItem,
|
|
60
65
|
normalizeItems,
|
|
61
66
|
pollToolName,
|
|
67
|
+
pollWithDedupe,
|
|
62
68
|
resolveConfig,
|
|
63
69
|
runAction,
|
|
64
70
|
runPoll,
|