@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 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
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ interface CliDeps {
3
+ load(modulePath: string): Promise<unknown>;
4
+ write(line: string): void;
5
+ env?: NodeJS.ProcessEnv;
6
+ }
7
+ declare function runCli(argv: string[], deps: CliDeps): Promise<number>;
8
+
9
+ export { type CliDeps, runCli };
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
+ };
@@ -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
+ }