@vornrun/connector-sdk 0.5.2
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 +213 -0
- package/dist/chunk-UANUTYUV.js +421 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +131 -0
- package/dist/index.d.ts +298 -0
- package/dist/index.js +66 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# @vornrun/connector-sdk
|
|
2
|
+
|
|
3
|
+
Build a Vorn pull connector in TypeScript and share it as an ordinary npm
|
|
4
|
+
package. No marketplace, no plugin host, no changes to Vorn itself: a connector
|
|
5
|
+
built with this SDK runs as an MCP stdio server, and Vorn's generic MCP
|
|
6
|
+
connector already knows how to talk to one.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @vornrun/connector-sdk
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Write a connector
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
// src/index.ts
|
|
16
|
+
import { defineConnector } from '@vornrun/connector-sdk'
|
|
17
|
+
|
|
18
|
+
export default defineConnector({
|
|
19
|
+
id: 'acme',
|
|
20
|
+
name: 'Acme Tickets',
|
|
21
|
+
version: '1.0.0',
|
|
22
|
+
config: [
|
|
23
|
+
{ key: 'apiToken', label: 'API token', required: true, secret: true },
|
|
24
|
+
{ key: 'baseUrl', label: 'Base URL', default: 'https://api.acme.test' }
|
|
25
|
+
],
|
|
26
|
+
triggers: [
|
|
27
|
+
{
|
|
28
|
+
type: 'newTicket',
|
|
29
|
+
label: 'New ticket',
|
|
30
|
+
async poll({ config, since, limit }) {
|
|
31
|
+
const url = new URL('/tickets', config.baseUrl)
|
|
32
|
+
if (since) url.searchParams.set('updated_after', since)
|
|
33
|
+
url.searchParams.set('per_page', String(limit ?? 100))
|
|
34
|
+
|
|
35
|
+
const response = await fetch(url, {
|
|
36
|
+
headers: { authorization: `Bearer ${config.apiToken}` }
|
|
37
|
+
})
|
|
38
|
+
if (!response.ok) throw new Error(`Acme returned ${response.status}`)
|
|
39
|
+
const tickets = (await response.json()) as AcmeTicket[]
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
items: tickets.map((ticket) => ({
|
|
43
|
+
externalId: ticket.id,
|
|
44
|
+
title: ticket.subject,
|
|
45
|
+
url: ticket.html_url,
|
|
46
|
+
description: ticket.body,
|
|
47
|
+
status: ticket.state,
|
|
48
|
+
updatedAt: ticket.updated_at,
|
|
49
|
+
data: { priority: ticket.priority }
|
|
50
|
+
}))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
actions: [
|
|
56
|
+
{
|
|
57
|
+
type: 'closeTicket',
|
|
58
|
+
label: 'Close ticket',
|
|
59
|
+
inputs: [{ key: 'id', label: 'Ticket id', required: true }],
|
|
60
|
+
// Optional: declared outputs show up in Vorn's `{{steps.…}}` autocomplete.
|
|
61
|
+
// Undeclared fields are still returned.
|
|
62
|
+
outputs: [{ key: 'closed' }],
|
|
63
|
+
async run({ id }, { config }) {
|
|
64
|
+
await fetch(`${config.baseUrl}/tickets/${id}/close`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { authorization: `Bearer ${config.apiToken}` }
|
|
67
|
+
})
|
|
68
|
+
return { closed: id }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Then a two-line bin:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
// src/bin.ts
|
|
79
|
+
import { serveConnector } from '@vornrun/connector-sdk'
|
|
80
|
+
import connector from './index'
|
|
81
|
+
|
|
82
|
+
await serveConnector(connector)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Publish it like any other package (`"bin": { "acme-connector": "dist/bin.js" }`).
|
|
86
|
+
|
|
87
|
+
## Poll a database instead of an API
|
|
88
|
+
|
|
89
|
+
Nothing about a trigger is HTTP-specific — it just returns items. A SQL pull
|
|
90
|
+
connector is the same shape:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { defineConnector } from '@vornrun/connector-sdk'
|
|
94
|
+
import postgres from 'postgres'
|
|
95
|
+
|
|
96
|
+
export default defineConnector({
|
|
97
|
+
id: 'orders-db',
|
|
98
|
+
name: 'Orders database',
|
|
99
|
+
config: [{ key: 'databaseUrl', label: 'Database URL', required: true, secret: true }],
|
|
100
|
+
triggers: [
|
|
101
|
+
{
|
|
102
|
+
type: 'newOrder',
|
|
103
|
+
label: 'New order',
|
|
104
|
+
async poll({ config, since, limit }) {
|
|
105
|
+
const sql = postgres(config.databaseUrl!)
|
|
106
|
+
try {
|
|
107
|
+
const rows = await sql`
|
|
108
|
+
SELECT id, reference, status, updated_at
|
|
109
|
+
FROM orders
|
|
110
|
+
WHERE updated_at > ${since ?? '1970-01-01'}
|
|
111
|
+
ORDER BY updated_at ASC
|
|
112
|
+
LIMIT ${limit ?? 200}
|
|
113
|
+
`
|
|
114
|
+
return {
|
|
115
|
+
items: rows.map((row) => ({
|
|
116
|
+
externalId: row.id,
|
|
117
|
+
title: `Order ${row.reference}`,
|
|
118
|
+
status: row.status,
|
|
119
|
+
updatedAt: row.updated_at
|
|
120
|
+
}))
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
await sql.end()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Two rules make a pull trigger reliable, and the SDK enforces both:
|
|
132
|
+
|
|
133
|
+
1. `externalId` must be stable — Vorn dedupes on it, so a changing id means
|
|
134
|
+
duplicate work items.
|
|
135
|
+
2. `updatedAt` must be monotonic and ISO-comparable — Vorn advances its poll
|
|
136
|
+
cursor from it. Sort ascending by that column and honor `since`.
|
|
137
|
+
|
|
138
|
+
## Paging a backlog
|
|
139
|
+
|
|
140
|
+
Return `hasMore: true` with a `nextCursor`, and Vorn (or `drainPoll` in tests)
|
|
141
|
+
will keep pulling bounded pages until the backlog is drained:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
async poll({ cursor, config }) {
|
|
145
|
+
const page = Number(cursor ?? '1')
|
|
146
|
+
const { items, totalPages } = await fetchPage(config, page)
|
|
147
|
+
return {
|
|
148
|
+
items: items.map(toItem),
|
|
149
|
+
nextCursor: String(page + 1),
|
|
150
|
+
hasMore: page < totalPages
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
A cursor that does not advance is rejected rather than looped on.
|
|
156
|
+
|
|
157
|
+
## Test it without running the app
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
import { createConnectorHarness } from '@vornrun/connector-sdk'
|
|
161
|
+
import connector from '../src/index'
|
|
162
|
+
|
|
163
|
+
const harness = createConnectorHarness(connector, {
|
|
164
|
+
config: { apiToken: 'test' },
|
|
165
|
+
now: () => '2026-08-05T00:00:00.000Z'
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('emits normalized tickets', async () => {
|
|
169
|
+
const page = await harness.poll('newTicket')
|
|
170
|
+
expect(page.items[0]).toMatchObject({ externalId: '1', status: 'open' })
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('does not redeliver the same backlog forever', async () => {
|
|
174
|
+
// Polls twice, carrying the watermark forward exactly as Vorn does.
|
|
175
|
+
expect(await harness.pollTwice('newTicket')).toEqual([])
|
|
176
|
+
})
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`harness.drain()` walks every page, `harness.execute()` runs an action, and
|
|
180
|
+
`harness.manifest()` returns what Vorn will see.
|
|
181
|
+
|
|
182
|
+
## Install it in Vorn
|
|
183
|
+
|
|
184
|
+
Run the CLI to get the exact connection settings:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
npx vorn-connector setup ./dist/index.js
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
It prints the values for **Settings → Connectors → MCP → New connection**:
|
|
191
|
+
|
|
192
|
+
| Field | Value |
|
|
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` |
|
|
198
|
+
|
|
199
|
+
Because the connector is a normal npm package, versions are pinned by the
|
|
200
|
+
`npx` argument and upgrades are a version bump — no separate registry.
|
|
201
|
+
|
|
202
|
+
## CLI
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
vorn-connector manifest <module> Print the manifest as JSON
|
|
206
|
+
vorn-connector setup <module> [trigger] Print the Vorn connection settings
|
|
207
|
+
vorn-connector poll <module> <trigger> Run one poll against the environment
|
|
208
|
+
vorn-connector serve <module> Serve on stdio (what Vorn runs)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`poll` accepts `--since <iso>` and `--limit <n>`, and reads the connector's
|
|
212
|
+
declared config from your shell environment — the fastest way to confirm
|
|
213
|
+
credentials and field mapping before wiring anything up.
|
|
@@ -0,0 +1,421 @@
|
|
|
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
|
+
// src/normalize.ts
|
|
83
|
+
var RESERVED_KEYS = [
|
|
84
|
+
"externalId",
|
|
85
|
+
"title",
|
|
86
|
+
"url",
|
|
87
|
+
"description",
|
|
88
|
+
"status",
|
|
89
|
+
"labels",
|
|
90
|
+
"assignee",
|
|
91
|
+
"updatedAt"
|
|
92
|
+
];
|
|
93
|
+
var UNSAFE_KEYS = ["__proto__", "constructor", "prototype"];
|
|
94
|
+
function isoTimestamp(value, fallback) {
|
|
95
|
+
if (value === void 0) return fallback;
|
|
96
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
97
|
+
if (Number.isNaN(date.getTime())) {
|
|
98
|
+
throw new Error(`Invalid updatedAt: ${String(value)}`);
|
|
99
|
+
}
|
|
100
|
+
return date.toISOString();
|
|
101
|
+
}
|
|
102
|
+
function normalizeItem(item, polledAt) {
|
|
103
|
+
const externalId = String(item.externalId ?? "").trim();
|
|
104
|
+
if (!externalId) {
|
|
105
|
+
throw new Error("Connector item is missing externalId");
|
|
106
|
+
}
|
|
107
|
+
if (!item.title || !item.title.trim()) {
|
|
108
|
+
throw new Error(`Connector item ${externalId} is missing title`);
|
|
109
|
+
}
|
|
110
|
+
const extra = {};
|
|
111
|
+
for (const [key, value] of Object.entries(item.data ?? {})) {
|
|
112
|
+
if (RESERVED_KEYS.includes(key)) continue;
|
|
113
|
+
if (UNSAFE_KEYS.includes(key)) continue;
|
|
114
|
+
extra[key] = value;
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
...extra,
|
|
118
|
+
externalId,
|
|
119
|
+
title: item.title,
|
|
120
|
+
url: item.url ?? "",
|
|
121
|
+
description: item.description ?? "",
|
|
122
|
+
status: item.status ?? "open",
|
|
123
|
+
labels: item.labels ?? [],
|
|
124
|
+
...item.assignee !== void 0 && { assignee: item.assignee },
|
|
125
|
+
updatedAt: isoTimestamp(item.updatedAt, polledAt)
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function normalizeItems(items, polledAt) {
|
|
129
|
+
const seen = /* @__PURE__ */ new Set();
|
|
130
|
+
return items.map((item) => {
|
|
131
|
+
const normalized = normalizeItem(item, polledAt);
|
|
132
|
+
if (seen.has(normalized.externalId)) {
|
|
133
|
+
throw new Error(`Duplicate externalId "${normalized.externalId}" in one poll page`);
|
|
134
|
+
}
|
|
135
|
+
seen.add(normalized.externalId);
|
|
136
|
+
return normalized;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/runtime.ts
|
|
141
|
+
var MAX_POLL_PAGES = 1e3;
|
|
142
|
+
async function runPoll(connector, triggerType, options = {}) {
|
|
143
|
+
const trigger = connector.triggers.find((entry) => entry.type === triggerType);
|
|
144
|
+
if (!trigger) {
|
|
145
|
+
throw new Error(`Connector ${connector.id} has no trigger "${triggerType}"`);
|
|
146
|
+
}
|
|
147
|
+
const now = options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
148
|
+
const polledAt = now();
|
|
149
|
+
const context = {
|
|
150
|
+
config: options.config ?? {},
|
|
151
|
+
...options.since !== void 0 && { since: options.since },
|
|
152
|
+
...options.cursor !== void 0 && { cursor: options.cursor },
|
|
153
|
+
...options.limit !== void 0 && { limit: options.limit },
|
|
154
|
+
now
|
|
155
|
+
};
|
|
156
|
+
const outcome = await trigger.poll(context);
|
|
157
|
+
if (!outcome || !Array.isArray(outcome.items)) {
|
|
158
|
+
throw new Error(`Trigger ${triggerType} did not return an items array`);
|
|
159
|
+
}
|
|
160
|
+
if (outcome.hasMore && !outcome.nextCursor) {
|
|
161
|
+
throw new Error(`Trigger ${triggerType} reported more pages without a nextCursor`);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
items: normalizeItems(outcome.items, polledAt),
|
|
165
|
+
...outcome.nextCursor !== void 0 && { nextCursor: outcome.nextCursor },
|
|
166
|
+
hasMore: outcome.hasMore === true
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
async function drainPoll(connector, triggerType, options = {}) {
|
|
170
|
+
const collected = [];
|
|
171
|
+
let cursor = options.cursor;
|
|
172
|
+
for (let page = 0; page < MAX_POLL_PAGES; page++) {
|
|
173
|
+
const result = await runPoll(connector, triggerType, {
|
|
174
|
+
...options,
|
|
175
|
+
...cursor !== void 0 && { cursor }
|
|
176
|
+
});
|
|
177
|
+
collected.push(...result.items);
|
|
178
|
+
if (!result.hasMore) return collected;
|
|
179
|
+
if (result.nextCursor === cursor) {
|
|
180
|
+
throw new Error(`Trigger ${triggerType} did not advance its cursor`);
|
|
181
|
+
}
|
|
182
|
+
cursor = result.nextCursor;
|
|
183
|
+
}
|
|
184
|
+
throw new Error(`Trigger ${triggerType} exceeded ${MAX_POLL_PAGES} pages`);
|
|
185
|
+
}
|
|
186
|
+
function coerceArg(value, type) {
|
|
187
|
+
if (typeof value !== "string") return value;
|
|
188
|
+
if (type === "number") {
|
|
189
|
+
const parsed = Number(value);
|
|
190
|
+
if (Number.isNaN(parsed)) throw new Error(`Expected a number, got "${value}"`);
|
|
191
|
+
return parsed;
|
|
192
|
+
}
|
|
193
|
+
if (type === "boolean") {
|
|
194
|
+
if (value === "true") return true;
|
|
195
|
+
if (value === "false") return false;
|
|
196
|
+
throw new Error(`Expected a boolean, got "${value}"`);
|
|
197
|
+
}
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
async function runAction(connector, actionType, args, options = {}) {
|
|
201
|
+
const action = connector.actions.find((entry) => entry.type === actionType);
|
|
202
|
+
if (!action) {
|
|
203
|
+
throw new Error(`Connector ${connector.id} has no action "${actionType}"`);
|
|
204
|
+
}
|
|
205
|
+
const coerced = { ...args };
|
|
206
|
+
for (const input of action.inputs ?? []) {
|
|
207
|
+
const value = coerced[input.key];
|
|
208
|
+
if (value === void 0 || value === "") {
|
|
209
|
+
if (input.required) throw new Error(`Action ${actionType} requires "${input.key}"`);
|
|
210
|
+
delete coerced[input.key];
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
coerced[input.key] = coerceArg(value, input.type);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Action ${actionType} argument "${input.key}": ${error instanceof Error ? error.message : String(error)}`,
|
|
218
|
+
{ cause: error }
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const output = await action.run(coerced, {
|
|
223
|
+
config: options.config ?? {},
|
|
224
|
+
now: options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString())
|
|
225
|
+
});
|
|
226
|
+
return output ?? {};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/setup.ts
|
|
230
|
+
function pollToolName(triggerType) {
|
|
231
|
+
return `poll_${triggerType}`;
|
|
232
|
+
}
|
|
233
|
+
var MANIFEST_TOOL = "vorn_connector_manifest";
|
|
234
|
+
function connectionSetup(connector, triggerType) {
|
|
235
|
+
const trigger = connector.triggers.find((entry) => entry.type === triggerType);
|
|
236
|
+
if (!trigger) {
|
|
237
|
+
throw new Error(`Connector ${connector.id} has no trigger "${triggerType}"`);
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
connectorId: connector.id,
|
|
241
|
+
triggerType,
|
|
242
|
+
filters: {
|
|
243
|
+
pollTool: pollToolName(triggerType),
|
|
244
|
+
itemsPath: "items",
|
|
245
|
+
idField: "externalId",
|
|
246
|
+
timestampField: "updatedAt",
|
|
247
|
+
titleField: "title",
|
|
248
|
+
urlField: "url"
|
|
249
|
+
},
|
|
250
|
+
env: connector.config.map((field) => ({
|
|
251
|
+
name: envNameFor(field.key, field.env),
|
|
252
|
+
required: field.required === true,
|
|
253
|
+
secret: field.secret === true,
|
|
254
|
+
...field.description !== void 0 && { description: field.description }
|
|
255
|
+
}))
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function connectorManifest(connector) {
|
|
259
|
+
return {
|
|
260
|
+
id: connector.id,
|
|
261
|
+
name: connector.name,
|
|
262
|
+
version: connector.version,
|
|
263
|
+
...connector.description !== void 0 && { description: connector.description },
|
|
264
|
+
triggers: connector.triggers.map((trigger) => ({
|
|
265
|
+
type: trigger.type,
|
|
266
|
+
label: trigger.label,
|
|
267
|
+
...trigger.description !== void 0 && { description: trigger.description },
|
|
268
|
+
setup: connectionSetup(connector, trigger.type)
|
|
269
|
+
})),
|
|
270
|
+
actions: connector.actions.map((action) => ({
|
|
271
|
+
type: action.type,
|
|
272
|
+
label: action.label,
|
|
273
|
+
...action.description !== void 0 && { description: action.description },
|
|
274
|
+
inputs: (action.inputs ?? []).map((input) => ({
|
|
275
|
+
key: input.key,
|
|
276
|
+
label: input.label,
|
|
277
|
+
type: input.type ?? "string",
|
|
278
|
+
required: input.required === true
|
|
279
|
+
}))
|
|
280
|
+
}))
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/server.ts
|
|
285
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
286
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
287
|
+
import { z } from "zod";
|
|
288
|
+
function json(value) {
|
|
289
|
+
return {
|
|
290
|
+
// Vorn reads `structuredContent` to build step output and to find the
|
|
291
|
+
// `items` array a poll returned; the text block keeps the result readable
|
|
292
|
+
// in any generic MCP client.
|
|
293
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
294
|
+
structuredContent: value
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function failure(error) {
|
|
298
|
+
return {
|
|
299
|
+
content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
|
|
300
|
+
isError: true
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function inputShape(inputs) {
|
|
304
|
+
const shape = {};
|
|
305
|
+
for (const input of inputs) {
|
|
306
|
+
const base = z.string().describe(input.description ?? input.label);
|
|
307
|
+
shape[input.key] = input.required ? base : base.optional();
|
|
308
|
+
}
|
|
309
|
+
return shape;
|
|
310
|
+
}
|
|
311
|
+
function scalar(type) {
|
|
312
|
+
if (type === "number") return z.number();
|
|
313
|
+
if (type === "boolean") return z.boolean();
|
|
314
|
+
return z.string();
|
|
315
|
+
}
|
|
316
|
+
function outputSchema(outputs) {
|
|
317
|
+
const shape = {};
|
|
318
|
+
for (const output of outputs) {
|
|
319
|
+
shape[output.key] = scalar(output.type).optional().describe(output.description ?? output.key);
|
|
320
|
+
}
|
|
321
|
+
return z.looseObject(shape);
|
|
322
|
+
}
|
|
323
|
+
function createConnectorServer(connector, options = {}) {
|
|
324
|
+
const server = new McpServer(
|
|
325
|
+
{ name: connector.id, version: connector.version },
|
|
326
|
+
{ capabilities: { tools: {} } }
|
|
327
|
+
);
|
|
328
|
+
let cached = options.config;
|
|
329
|
+
const config = () => cached ??= resolveConfig(connector);
|
|
330
|
+
server.registerTool(
|
|
331
|
+
MANIFEST_TOOL,
|
|
332
|
+
{
|
|
333
|
+
description: `Describe the ${connector.name} connector and how to configure it`,
|
|
334
|
+
inputSchema: {},
|
|
335
|
+
outputSchema: z.looseObject({})
|
|
336
|
+
},
|
|
337
|
+
() => json(connectorManifest(connector))
|
|
338
|
+
);
|
|
339
|
+
for (const trigger of connector.triggers) {
|
|
340
|
+
server.registerTool(
|
|
341
|
+
pollToolName(trigger.type),
|
|
342
|
+
{
|
|
343
|
+
description: trigger.description ?? `Poll ${connector.name} for ${trigger.label}`,
|
|
344
|
+
inputSchema: {
|
|
345
|
+
since: z.string().optional().describe("Only return items changed after this ISO timestamp"),
|
|
346
|
+
cursor: z.string().optional().describe("Opaque cursor from a previous page"),
|
|
347
|
+
limit: z.string().optional().describe("Maximum number of items to return")
|
|
348
|
+
},
|
|
349
|
+
outputSchema: z.looseObject({
|
|
350
|
+
items: z.array(z.looseObject({})).describe("Normalized items"),
|
|
351
|
+
nextCursor: z.string().optional().describe("Cursor for the next page"),
|
|
352
|
+
hasMore: z.boolean().describe("Whether another page is immediately available")
|
|
353
|
+
})
|
|
354
|
+
},
|
|
355
|
+
async (args) => {
|
|
356
|
+
try {
|
|
357
|
+
const limit = args.limit === void 0 ? void 0 : Number(args.limit);
|
|
358
|
+
if (limit !== void 0 && !Number.isFinite(limit)) {
|
|
359
|
+
throw new Error(`Invalid limit "${args.limit}"`);
|
|
360
|
+
}
|
|
361
|
+
return json(
|
|
362
|
+
await runPoll(connector, trigger.type, {
|
|
363
|
+
config: config(),
|
|
364
|
+
...args.since !== void 0 && { since: args.since },
|
|
365
|
+
...args.cursor !== void 0 && { cursor: args.cursor },
|
|
366
|
+
...limit !== void 0 && { limit },
|
|
367
|
+
...options.now && { now: options.now }
|
|
368
|
+
})
|
|
369
|
+
);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
return failure(error);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
for (const action of connector.actions) {
|
|
377
|
+
server.registerTool(
|
|
378
|
+
action.type,
|
|
379
|
+
{
|
|
380
|
+
description: action.description ?? `${action.label} in ${connector.name}`,
|
|
381
|
+
inputSchema: inputShape(action.inputs ?? []),
|
|
382
|
+
outputSchema: outputSchema(action.outputs ?? [])
|
|
383
|
+
},
|
|
384
|
+
async (args) => {
|
|
385
|
+
try {
|
|
386
|
+
return json(
|
|
387
|
+
await runAction(connector, action.type, args, {
|
|
388
|
+
config: config(),
|
|
389
|
+
...options.now && { now: options.now }
|
|
390
|
+
})
|
|
391
|
+
);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
return failure(error);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
return server;
|
|
399
|
+
}
|
|
400
|
+
async function serveConnector(connector, options = {}) {
|
|
401
|
+
const server = createConnectorServer(connector, options);
|
|
402
|
+
await server.connect(new StdioServerTransport());
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export {
|
|
406
|
+
envNameFor,
|
|
407
|
+
defineConnector,
|
|
408
|
+
resolveConfig,
|
|
409
|
+
normalizeItem,
|
|
410
|
+
normalizeItems,
|
|
411
|
+
MAX_POLL_PAGES,
|
|
412
|
+
runPoll,
|
|
413
|
+
drainPoll,
|
|
414
|
+
runAction,
|
|
415
|
+
pollToolName,
|
|
416
|
+
MANIFEST_TOOL,
|
|
417
|
+
connectionSetup,
|
|
418
|
+
connectorManifest,
|
|
419
|
+
createConnectorServer,
|
|
420
|
+
serveConnector
|
|
421
|
+
};
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
connectionSetup,
|
|
4
|
+
connectorManifest,
|
|
5
|
+
resolveConfig,
|
|
6
|
+
runPoll,
|
|
7
|
+
serveConnector
|
|
8
|
+
} from "./chunk-UANUTYUV.js";
|
|
9
|
+
|
|
10
|
+
// src/cli.ts
|
|
11
|
+
import { pathToFileURL } from "url";
|
|
12
|
+
import { resolve } from "path";
|
|
13
|
+
var USAGE = `vorn-connector <command> <module> [options]
|
|
14
|
+
|
|
15
|
+
Commands:
|
|
16
|
+
manifest <module> Print the connector manifest as JSON
|
|
17
|
+
setup <module> [trigger] Print the Vorn connection settings to paste
|
|
18
|
+
poll <module> <trigger> Run one poll against the current environment
|
|
19
|
+
serve <module> Serve the connector on stdio (what Vorn runs)
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--since <iso> Lower bound passed to poll
|
|
23
|
+
--limit <n> Maximum items to request`;
|
|
24
|
+
function parseFlags(args) {
|
|
25
|
+
const flags = {};
|
|
26
|
+
for (let index = 0; index < args.length; index++) {
|
|
27
|
+
const arg = args[index];
|
|
28
|
+
if (!arg.startsWith("--")) continue;
|
|
29
|
+
const value = args[index + 1];
|
|
30
|
+
if (value === void 0 || value.startsWith("--")) {
|
|
31
|
+
throw new Error(`Missing value for ${arg}`);
|
|
32
|
+
}
|
|
33
|
+
flags[arg.slice(2)] = value;
|
|
34
|
+
index++;
|
|
35
|
+
}
|
|
36
|
+
return flags;
|
|
37
|
+
}
|
|
38
|
+
function pickConnector(loaded, modulePath) {
|
|
39
|
+
const module = loaded;
|
|
40
|
+
const candidate = module?.default ?? module?.connector;
|
|
41
|
+
const connector = candidate;
|
|
42
|
+
if (!connector || typeof connector !== "object" || !Array.isArray(connector.triggers)) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`${modulePath} does not export a connector built with defineConnector() (default or named "connector")`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return connector;
|
|
48
|
+
}
|
|
49
|
+
async function runCli(argv, deps) {
|
|
50
|
+
const [command, modulePath, ...rest] = argv;
|
|
51
|
+
if (!command || command === "help" || command === "--help") {
|
|
52
|
+
deps.write(USAGE);
|
|
53
|
+
return command ? 0 : 1;
|
|
54
|
+
}
|
|
55
|
+
if (!modulePath) {
|
|
56
|
+
deps.write(`Missing <module> argument
|
|
57
|
+
|
|
58
|
+
${USAGE}`);
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
const connector = pickConnector(await deps.load(modulePath), modulePath);
|
|
62
|
+
const positional = rest.filter((arg) => !arg.startsWith("--"));
|
|
63
|
+
const flags = parseFlags(rest);
|
|
64
|
+
switch (command) {
|
|
65
|
+
case "manifest":
|
|
66
|
+
deps.write(JSON.stringify(connectorManifest(connector), null, 2));
|
|
67
|
+
return 0;
|
|
68
|
+
case "setup": {
|
|
69
|
+
const triggers = positional[0] ? [positional[0]] : connector.triggers.map((t) => t.type);
|
|
70
|
+
for (const triggerType of triggers) {
|
|
71
|
+
const setup = connectionSetup(connector, triggerType);
|
|
72
|
+
deps.write(`# ${connector.name} \u2014 ${triggerType}`);
|
|
73
|
+
deps.write(`Command: npx`);
|
|
74
|
+
deps.write(`Arguments: ["-y", "<your-package>"]`);
|
|
75
|
+
deps.write(`Filters: ${JSON.stringify(setup.filters, null, 2)}`);
|
|
76
|
+
if (setup.env.length > 0) {
|
|
77
|
+
deps.write(
|
|
78
|
+
`Environment: ${setup.env.map((entry) => `${entry.name}${entry.required ? " (required)" : ""}`).join(", ")}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
case "poll": {
|
|
85
|
+
const triggerType = positional[0];
|
|
86
|
+
if (!triggerType) {
|
|
87
|
+
deps.write(`Missing <trigger> argument
|
|
88
|
+
|
|
89
|
+
${USAGE}`);
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
const limit = flags.limit === void 0 ? void 0 : Number(flags.limit);
|
|
93
|
+
if (limit !== void 0 && !Number.isFinite(limit)) {
|
|
94
|
+
deps.write(`Invalid limit "${flags.limit}"`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
const page = await runPoll(connector, triggerType, {
|
|
98
|
+
config: resolveConfig(connector, deps.env ?? process.env),
|
|
99
|
+
...flags.since !== void 0 && { since: flags.since },
|
|
100
|
+
...limit !== void 0 && { limit }
|
|
101
|
+
});
|
|
102
|
+
deps.write(JSON.stringify(page, null, 2));
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
case "serve":
|
|
106
|
+
await serveConnector(connector);
|
|
107
|
+
return 0;
|
|
108
|
+
default:
|
|
109
|
+
deps.write(`Unknown command "${command}"
|
|
110
|
+
|
|
111
|
+
${USAGE}`);
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
var invokedDirectly = process.argv[1] !== void 0 && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
116
|
+
if (invokedDirectly) {
|
|
117
|
+
runCli(process.argv.slice(2), {
|
|
118
|
+
load: (modulePath) => modulePath.startsWith(".") || modulePath.startsWith("/") ? import(pathToFileURL(resolve(modulePath)).href) : import(modulePath),
|
|
119
|
+
write: (line) => process.stdout.write(`${line}
|
|
120
|
+
`)
|
|
121
|
+
}).then((code) => {
|
|
122
|
+
process.exitCode = code;
|
|
123
|
+
}).catch((error) => {
|
|
124
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
125
|
+
`);
|
|
126
|
+
process.exitCode = 1;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
export {
|
|
130
|
+
runCli
|
|
131
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Author-facing types for Vorn connectors.
|
|
5
|
+
*
|
|
6
|
+
* A connector written with this SDK runs as an ordinary MCP stdio server, so
|
|
7
|
+
* it is shared as a normal npm package and installed by pointing a Vorn
|
|
8
|
+
* connection at `npx -y <package>`. Nothing about the host app has to change
|
|
9
|
+
* to accept a new connector.
|
|
10
|
+
*/
|
|
11
|
+
/** A raw item as the author's code returns it. Only id and title are required. */
|
|
12
|
+
interface ConnectorItem {
|
|
13
|
+
/** Stable upstream identity. Vorn dedupes on this, so it must not change. */
|
|
14
|
+
externalId: string | number;
|
|
15
|
+
title: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
/** Raw upstream status (`open`, `Active`, `In Progress`, …). */
|
|
19
|
+
status?: string;
|
|
20
|
+
labels?: string[];
|
|
21
|
+
assignee?: string;
|
|
22
|
+
/**
|
|
23
|
+
* When the item last changed. Vorn advances its poll cursor from this field,
|
|
24
|
+
* so it must be monotonic per item and comparable as an ISO 8601 string.
|
|
25
|
+
* Defaults to poll time when omitted.
|
|
26
|
+
*/
|
|
27
|
+
updatedAt?: string | Date;
|
|
28
|
+
/** Extra fields to expose to workflow templates as `{{trigger.item.<key>}}`. */
|
|
29
|
+
data?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
/** A connector item after normalization. This is the exact JSON Vorn sees. */
|
|
32
|
+
interface NormalizedItem extends Record<string, unknown> {
|
|
33
|
+
externalId: string;
|
|
34
|
+
title: string;
|
|
35
|
+
url: string;
|
|
36
|
+
description: string;
|
|
37
|
+
status: string;
|
|
38
|
+
labels: string[];
|
|
39
|
+
updatedAt: string;
|
|
40
|
+
assignee?: string;
|
|
41
|
+
}
|
|
42
|
+
/** Declares a value the connector needs at runtime, read from the environment. */
|
|
43
|
+
interface ConnectorConfigField {
|
|
44
|
+
key: string;
|
|
45
|
+
label: string;
|
|
46
|
+
/** Environment variable the value is read from. Defaults to CONSTANT_CASE(key). */
|
|
47
|
+
env?: string;
|
|
48
|
+
required?: boolean;
|
|
49
|
+
/** Secrets are stored encrypted by Vorn and never printed by the CLI. */
|
|
50
|
+
secret?: boolean;
|
|
51
|
+
description?: string;
|
|
52
|
+
default?: string;
|
|
53
|
+
}
|
|
54
|
+
type ConnectorConfig = Record<string, string | undefined>;
|
|
55
|
+
interface PollContext {
|
|
56
|
+
config: ConnectorConfig;
|
|
57
|
+
/**
|
|
58
|
+
* Lower bound the host asked for, when it was able to supply one. Treat it
|
|
59
|
+
* as a hint: returning older items is safe because Vorn dedupes, but
|
|
60
|
+
* returning fewer than everything after `since` loses events.
|
|
61
|
+
*/
|
|
62
|
+
since?: string;
|
|
63
|
+
/** Opaque cursor previously returned by this trigger, when supplied. */
|
|
64
|
+
cursor?: string;
|
|
65
|
+
/** Upper bound on items to return in one page. */
|
|
66
|
+
limit?: number;
|
|
67
|
+
/** Injectable clock so tests are deterministic. */
|
|
68
|
+
now(): string;
|
|
69
|
+
}
|
|
70
|
+
interface PollOutcome {
|
|
71
|
+
items: ConnectorItem[];
|
|
72
|
+
nextCursor?: string;
|
|
73
|
+
hasMore?: boolean;
|
|
74
|
+
}
|
|
75
|
+
interface TriggerDefinition {
|
|
76
|
+
/** Event key, e.g. `workItemCreated`. Becomes the `poll_<type>` MCP tool. */
|
|
77
|
+
type: string;
|
|
78
|
+
label: string;
|
|
79
|
+
description?: string;
|
|
80
|
+
poll(context: PollContext): Promise<PollOutcome> | PollOutcome;
|
|
81
|
+
}
|
|
82
|
+
interface ActionInputField {
|
|
83
|
+
key: string;
|
|
84
|
+
label: string;
|
|
85
|
+
type?: 'string' | 'number' | 'boolean';
|
|
86
|
+
required?: boolean;
|
|
87
|
+
description?: string;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A field the action is known to return. Declaring these is optional — extra
|
|
91
|
+
* keys always pass through — but declared fields show up in Vorn's variable
|
|
92
|
+
* autocomplete as `{{steps.<action>.<key>}}`.
|
|
93
|
+
*/
|
|
94
|
+
interface ActionOutputField {
|
|
95
|
+
key: string;
|
|
96
|
+
type?: 'string' | 'number' | 'boolean';
|
|
97
|
+
description?: string;
|
|
98
|
+
}
|
|
99
|
+
interface ActionContext {
|
|
100
|
+
config: ConnectorConfig;
|
|
101
|
+
now(): string;
|
|
102
|
+
}
|
|
103
|
+
interface ActionDefinition {
|
|
104
|
+
/** Action key, e.g. `closeWorkItem`. Becomes an MCP tool of the same name. */
|
|
105
|
+
type: string;
|
|
106
|
+
label: string;
|
|
107
|
+
description?: string;
|
|
108
|
+
inputs?: ActionInputField[];
|
|
109
|
+
outputs?: ActionOutputField[];
|
|
110
|
+
run(args: Record<string, unknown>, context: ActionContext): Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
|
|
111
|
+
}
|
|
112
|
+
interface ConnectorDefinition {
|
|
113
|
+
/** Stable connector id, e.g. `azure-devops`. */
|
|
114
|
+
id: string;
|
|
115
|
+
name: string;
|
|
116
|
+
version?: string;
|
|
117
|
+
description?: string;
|
|
118
|
+
config?: ConnectorConfigField[];
|
|
119
|
+
triggers?: TriggerDefinition[];
|
|
120
|
+
actions?: ActionDefinition[];
|
|
121
|
+
}
|
|
122
|
+
/** A validated definition. Every accessor below is guaranteed non-null. */
|
|
123
|
+
interface Connector extends ConnectorDefinition {
|
|
124
|
+
readonly version: string;
|
|
125
|
+
readonly config: ConnectorConfigField[];
|
|
126
|
+
readonly triggers: TriggerDefinition[];
|
|
127
|
+
readonly actions: ActionDefinition[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Environment variable a config field reads from, e.g. `apiToken` → `API_TOKEN`. */
|
|
131
|
+
declare function envNameFor(key: string, explicit?: string): string;
|
|
132
|
+
/**
|
|
133
|
+
* Validate a connector definition and fill in its defaults.
|
|
134
|
+
*
|
|
135
|
+
* Failing here — at import time — is the whole point: a typo in a trigger
|
|
136
|
+
* type or a duplicate action key otherwise surfaces as a silently missing
|
|
137
|
+
* MCP tool once the connector is already installed in someone's app.
|
|
138
|
+
*/
|
|
139
|
+
declare function defineConnector(definition: ConnectorDefinition): Connector;
|
|
140
|
+
/**
|
|
141
|
+
* Read the connector's declared config out of the environment. Vorn supplies
|
|
142
|
+
* these through the connection's `env` / `secretEnv` maps, so a missing
|
|
143
|
+
* required value is a setup mistake worth reporting by name rather than
|
|
144
|
+
* letting the first API call fail with a confusing 401.
|
|
145
|
+
*/
|
|
146
|
+
declare function resolveConfig(connector: Connector, env?: NodeJS.ProcessEnv): ConnectorConfig;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Turn an author-supplied item into the flat JSON shape Vorn consumes.
|
|
150
|
+
*
|
|
151
|
+
* Normalizing here rather than in each connector is what lets one host-side
|
|
152
|
+
* poll configuration (`idField: externalId`, `timestampField: updatedAt`, …)
|
|
153
|
+
* work for every SDK connector.
|
|
154
|
+
*/
|
|
155
|
+
declare function normalizeItem(item: ConnectorItem, polledAt: string): NormalizedItem;
|
|
156
|
+
/**
|
|
157
|
+
* Normalize a page and reject duplicate ids within it. Two items sharing an
|
|
158
|
+
* id in one page means one of them would be silently dropped by Vorn's
|
|
159
|
+
* dedupe, which looks like data loss long after the fact.
|
|
160
|
+
*/
|
|
161
|
+
declare function normalizeItems(items: ConnectorItem[], polledAt: string): NormalizedItem[];
|
|
162
|
+
|
|
163
|
+
interface PollPage {
|
|
164
|
+
items: NormalizedItem[];
|
|
165
|
+
nextCursor?: string;
|
|
166
|
+
hasMore: boolean;
|
|
167
|
+
}
|
|
168
|
+
interface RunPollOptions {
|
|
169
|
+
config?: ConnectorConfig;
|
|
170
|
+
since?: string;
|
|
171
|
+
cursor?: string;
|
|
172
|
+
limit?: number;
|
|
173
|
+
now?: () => string;
|
|
174
|
+
}
|
|
175
|
+
/** Longest chain of pages `drainPoll` will follow before calling it a bug. */
|
|
176
|
+
declare const MAX_POLL_PAGES = 1000;
|
|
177
|
+
/**
|
|
178
|
+
* Run one poll page and normalize it. Shared by the MCP server, the CLI and
|
|
179
|
+
* the test harness so all three observe exactly what Vorn will observe.
|
|
180
|
+
*/
|
|
181
|
+
declare function runPoll(connector: Connector, triggerType: string, options?: RunPollOptions): Promise<PollPage>;
|
|
182
|
+
/**
|
|
183
|
+
* Follow `hasMore` to the end of a trigger's backlog. Mirrors how Vorn drains
|
|
184
|
+
* a connector, including its refusal to follow a cursor that does not move —
|
|
185
|
+
* so an author sees the infinite loop in a unit test instead of in the app.
|
|
186
|
+
*/
|
|
187
|
+
declare function drainPoll(connector: Connector, triggerType: string, options?: RunPollOptions): Promise<NormalizedItem[]>;
|
|
188
|
+
interface RunActionOptions {
|
|
189
|
+
config?: ConnectorConfig;
|
|
190
|
+
now?: () => string;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Run an action with its declared inputs validated and coerced. Vorn renders
|
|
194
|
+
* every action argument as a template string, so numbers and booleans arrive
|
|
195
|
+
* as text and have to be converted back here.
|
|
196
|
+
*/
|
|
197
|
+
declare function runAction(connector: Connector, actionType: string, args: Record<string, unknown>, options?: RunActionOptions): Promise<Record<string, unknown>>;
|
|
198
|
+
|
|
199
|
+
/** MCP tool name a trigger is served under. */
|
|
200
|
+
declare function pollToolName(triggerType: string): string;
|
|
201
|
+
/** Tool that reports the connector's manifest and setup hints. */
|
|
202
|
+
declare const MANIFEST_TOOL = "vorn_connector_manifest";
|
|
203
|
+
interface ConnectionSetup {
|
|
204
|
+
connectorId: string;
|
|
205
|
+
triggerType: string;
|
|
206
|
+
/** Values to paste into Vorn's MCP connection form. */
|
|
207
|
+
filters: {
|
|
208
|
+
pollTool: string;
|
|
209
|
+
itemsPath: 'items';
|
|
210
|
+
idField: 'externalId';
|
|
211
|
+
timestampField: 'updatedAt';
|
|
212
|
+
titleField: 'title';
|
|
213
|
+
urlField: 'url';
|
|
214
|
+
};
|
|
215
|
+
/** Environment variable names the connector reads. */
|
|
216
|
+
env: Array<{
|
|
217
|
+
name: string;
|
|
218
|
+
required: boolean;
|
|
219
|
+
secret: boolean;
|
|
220
|
+
description?: string;
|
|
221
|
+
}>;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Describe how to wire one trigger into a Vorn MCP connection.
|
|
225
|
+
*
|
|
226
|
+
* Every SDK connector normalizes to the same field names, so this mapping is
|
|
227
|
+
* fixed; it is generated rather than documented so a rename in the SDK cannot
|
|
228
|
+
* drift away from the setup instructions users copy.
|
|
229
|
+
*/
|
|
230
|
+
declare function connectionSetup(connector: Connector, triggerType: string): ConnectionSetup;
|
|
231
|
+
interface ConnectorManifest {
|
|
232
|
+
id: string;
|
|
233
|
+
name: string;
|
|
234
|
+
version: string;
|
|
235
|
+
description?: string;
|
|
236
|
+
triggers: Array<{
|
|
237
|
+
type: string;
|
|
238
|
+
label: string;
|
|
239
|
+
description?: string;
|
|
240
|
+
setup: ConnectionSetup;
|
|
241
|
+
}>;
|
|
242
|
+
actions: Array<{
|
|
243
|
+
type: string;
|
|
244
|
+
label: string;
|
|
245
|
+
description?: string;
|
|
246
|
+
inputs: Array<{
|
|
247
|
+
key: string;
|
|
248
|
+
label: string;
|
|
249
|
+
type: string;
|
|
250
|
+
required: boolean;
|
|
251
|
+
}>;
|
|
252
|
+
}>;
|
|
253
|
+
}
|
|
254
|
+
/** Full machine-readable description of a connector, served over MCP and printed by the CLI. */
|
|
255
|
+
declare function connectorManifest(connector: Connector): ConnectorManifest;
|
|
256
|
+
|
|
257
|
+
interface ConnectorServerOptions {
|
|
258
|
+
/** Resolved connector configuration. Defaults to reading `process.env`. */
|
|
259
|
+
config?: ConnectorConfig;
|
|
260
|
+
now?: () => string;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Expose a connector as an MCP server.
|
|
264
|
+
*
|
|
265
|
+
* Each trigger becomes a `poll_<type>` tool returning the normalized page,
|
|
266
|
+
* each action becomes a tool of the same name, and `vorn_connector_manifest`
|
|
267
|
+
* reports everything needed to configure the connection. That is the entire
|
|
268
|
+
* contract — Vorn's generic MCP connector consumes it with no host changes.
|
|
269
|
+
*/
|
|
270
|
+
declare function createConnectorServer(connector: Connector, options?: ConnectorServerOptions): McpServer;
|
|
271
|
+
/** Serve a connector on stdio. This is the one line a connector's bin needs. */
|
|
272
|
+
declare function serveConnector(connector: Connector, options?: ConnectorServerOptions): Promise<void>;
|
|
273
|
+
|
|
274
|
+
interface HarnessOptions {
|
|
275
|
+
config?: ConnectorConfig;
|
|
276
|
+
/** Fixed clock, so `updatedAt` defaults and cursors are deterministic. */
|
|
277
|
+
now?: () => string;
|
|
278
|
+
}
|
|
279
|
+
interface ConnectorHarness {
|
|
280
|
+
poll(triggerType: string, options?: RunPollOptions): Promise<PollPage>;
|
|
281
|
+
drain(triggerType: string, options?: RunPollOptions): Promise<NormalizedItem[]>;
|
|
282
|
+
execute(actionType: string, args?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
283
|
+
manifest(): ConnectorManifest;
|
|
284
|
+
/**
|
|
285
|
+
* Poll repeatedly the way Vorn does — carrying the newest `updatedAt`
|
|
286
|
+
* forward as the watermark — and return only items a real installation
|
|
287
|
+
* would treat as new. Catches the classic connector bug where a poll
|
|
288
|
+
* ignores its lower bound and re-delivers the same backlog forever.
|
|
289
|
+
*/
|
|
290
|
+
pollTwice(triggerType: string, options?: RunPollOptions): Promise<NormalizedItem[]>;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Run a connector in-process, exactly as the MCP server would, without
|
|
294
|
+
* spawning anything. Authors get real assertions in a plain unit test.
|
|
295
|
+
*/
|
|
296
|
+
declare function createConnectorHarness(connector: Connector, harnessOptions?: HarnessOptions): ConnectorHarness;
|
|
297
|
+
|
|
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 };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MANIFEST_TOOL,
|
|
3
|
+
MAX_POLL_PAGES,
|
|
4
|
+
connectionSetup,
|
|
5
|
+
connectorManifest,
|
|
6
|
+
createConnectorServer,
|
|
7
|
+
defineConnector,
|
|
8
|
+
drainPoll,
|
|
9
|
+
envNameFor,
|
|
10
|
+
normalizeItem,
|
|
11
|
+
normalizeItems,
|
|
12
|
+
pollToolName,
|
|
13
|
+
resolveConfig,
|
|
14
|
+
runAction,
|
|
15
|
+
runPoll,
|
|
16
|
+
serveConnector
|
|
17
|
+
} from "./chunk-UANUTYUV.js";
|
|
18
|
+
|
|
19
|
+
// src/harness.ts
|
|
20
|
+
function createConnectorHarness(connector, harnessOptions = {}) {
|
|
21
|
+
const defaults = (options = {}) => ({
|
|
22
|
+
...harnessOptions.config && { config: harnessOptions.config },
|
|
23
|
+
...harnessOptions.now && { now: harnessOptions.now },
|
|
24
|
+
...options
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
poll: (triggerType, options) => runPoll(connector, triggerType, defaults(options)),
|
|
28
|
+
drain: (triggerType, options) => drainPoll(connector, triggerType, defaults(options)),
|
|
29
|
+
execute: (actionType, args = {}) => runAction(connector, actionType, args, {
|
|
30
|
+
...harnessOptions.config && { config: harnessOptions.config },
|
|
31
|
+
...harnessOptions.now && { now: harnessOptions.now }
|
|
32
|
+
}),
|
|
33
|
+
manifest: () => connectorManifest(connector),
|
|
34
|
+
async pollTwice(triggerType, options) {
|
|
35
|
+
const first = await drainPoll(connector, triggerType, defaults(options));
|
|
36
|
+
const watermark = first.reduce(
|
|
37
|
+
(newest, item) => newest === void 0 || item.updatedAt > newest ? item.updatedAt : newest,
|
|
38
|
+
options?.since
|
|
39
|
+
);
|
|
40
|
+
const second = await drainPoll(
|
|
41
|
+
connector,
|
|
42
|
+
triggerType,
|
|
43
|
+
defaults({ ...options, ...watermark !== void 0 && { since: watermark } })
|
|
44
|
+
);
|
|
45
|
+
return watermark === void 0 ? second : second.filter((item) => item.updatedAt > watermark);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export {
|
|
50
|
+
MANIFEST_TOOL,
|
|
51
|
+
MAX_POLL_PAGES,
|
|
52
|
+
connectionSetup,
|
|
53
|
+
connectorManifest,
|
|
54
|
+
createConnectorHarness,
|
|
55
|
+
createConnectorServer,
|
|
56
|
+
defineConnector,
|
|
57
|
+
drainPoll,
|
|
58
|
+
envNameFor,
|
|
59
|
+
normalizeItem,
|
|
60
|
+
normalizeItems,
|
|
61
|
+
pollToolName,
|
|
62
|
+
resolveConfig,
|
|
63
|
+
runAction,
|
|
64
|
+
runPoll,
|
|
65
|
+
serveConnector
|
|
66
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vornrun/connector-sdk",
|
|
3
|
+
"version": "0.5.2",
|
|
4
|
+
"description": "Build and share Vorn pull connectors as ordinary npm packages",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Javier Canizalez <javier-canizalez@outlook.com>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/vorn-run/vorn.git",
|
|
11
|
+
"directory": "packages/connector-sdk"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"vorn",
|
|
15
|
+
"connector",
|
|
16
|
+
"mcp",
|
|
17
|
+
"model-context-protocol",
|
|
18
|
+
"integration"
|
|
19
|
+
],
|
|
20
|
+
"bin": {
|
|
21
|
+
"vorn-connector": "dist/cli.js"
|
|
22
|
+
},
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
40
|
+
"zod": "^4.4.3"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"tsup": "^8.5.1",
|
|
44
|
+
"typescript": "^6.0.3"
|
|
45
|
+
}
|
|
46
|
+
}
|