rowbound 1.0.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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +258 -0
  3. package/dist/adapters/adapter.d.ts +1 -0
  4. package/dist/adapters/adapter.js +1 -0
  5. package/dist/adapters/sheets/sheets-adapter.d.ts +66 -0
  6. package/dist/adapters/sheets/sheets-adapter.js +531 -0
  7. package/dist/cli/config.d.ts +2 -0
  8. package/dist/cli/config.js +397 -0
  9. package/dist/cli/env.d.ts +3 -0
  10. package/dist/cli/env.js +103 -0
  11. package/dist/cli/format.d.ts +5 -0
  12. package/dist/cli/format.js +6 -0
  13. package/dist/cli/index.d.ts +2 -0
  14. package/dist/cli/index.js +39 -0
  15. package/dist/cli/init.d.ts +10 -0
  16. package/dist/cli/init.js +72 -0
  17. package/dist/cli/run.d.ts +2 -0
  18. package/dist/cli/run.js +212 -0
  19. package/dist/cli/runs.d.ts +2 -0
  20. package/dist/cli/runs.js +108 -0
  21. package/dist/cli/status.d.ts +2 -0
  22. package/dist/cli/status.js +108 -0
  23. package/dist/cli/sync.d.ts +2 -0
  24. package/dist/cli/sync.js +84 -0
  25. package/dist/cli/watch.d.ts +2 -0
  26. package/dist/cli/watch.js +348 -0
  27. package/dist/core/condition.d.ts +25 -0
  28. package/dist/core/condition.js +66 -0
  29. package/dist/core/defaults.d.ts +3 -0
  30. package/dist/core/defaults.js +7 -0
  31. package/dist/core/engine.d.ts +50 -0
  32. package/dist/core/engine.js +234 -0
  33. package/dist/core/env.d.ts +13 -0
  34. package/dist/core/env.js +72 -0
  35. package/dist/core/exec.d.ts +24 -0
  36. package/dist/core/exec.js +134 -0
  37. package/dist/core/extractor.d.ts +10 -0
  38. package/dist/core/extractor.js +33 -0
  39. package/dist/core/http-client.d.ts +32 -0
  40. package/dist/core/http-client.js +161 -0
  41. package/dist/core/rate-limiter.d.ts +25 -0
  42. package/dist/core/rate-limiter.js +64 -0
  43. package/dist/core/reconcile.d.ts +24 -0
  44. package/dist/core/reconcile.js +192 -0
  45. package/dist/core/run-format.d.ts +39 -0
  46. package/dist/core/run-format.js +201 -0
  47. package/dist/core/run-state.d.ts +64 -0
  48. package/dist/core/run-state.js +141 -0
  49. package/dist/core/run-tracker.d.ts +15 -0
  50. package/dist/core/run-tracker.js +57 -0
  51. package/dist/core/safe-compare.d.ts +8 -0
  52. package/dist/core/safe-compare.js +19 -0
  53. package/dist/core/shell-escape.d.ts +7 -0
  54. package/dist/core/shell-escape.js +9 -0
  55. package/dist/core/tab-resolver.d.ts +17 -0
  56. package/dist/core/tab-resolver.js +44 -0
  57. package/dist/core/template.d.ts +32 -0
  58. package/dist/core/template.js +82 -0
  59. package/dist/core/types.d.ts +105 -0
  60. package/dist/core/types.js +2 -0
  61. package/dist/core/url-guard.d.ts +21 -0
  62. package/dist/core/url-guard.js +184 -0
  63. package/dist/core/validator.d.ts +11 -0
  64. package/dist/core/validator.js +261 -0
  65. package/dist/core/waterfall.d.ts +26 -0
  66. package/dist/core/waterfall.js +55 -0
  67. package/dist/index.d.ts +15 -0
  68. package/dist/index.js +16 -0
  69. package/dist/mcp/server.d.ts +1 -0
  70. package/dist/mcp/server.js +943 -0
  71. package/package.json +67 -0
@@ -0,0 +1,943 @@
1
+ import { createRequire } from "node:module";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod/v4";
5
+ import { SheetsAdapter } from "../adapters/sheets/sheets-adapter.js";
6
+ import { defaultSettings } from "../core/defaults.js";
7
+ import { runPipeline } from "../core/engine.js";
8
+ import { buildSafeEnv } from "../core/env.js";
9
+ import { reconcile } from "../core/reconcile.js";
10
+ import { formatRunDetail, formatRunList } from "../core/run-format.js";
11
+ import { listRuns, readRunState } from "../core/run-state.js";
12
+ import { safeCompare } from "../core/safe-compare.js";
13
+ import { getTabConfig } from "../core/tab-resolver.js";
14
+ import { validateConfig } from "../core/validator.js";
15
+ const require = createRequire(import.meta.url);
16
+ const pkg = require("../../package.json");
17
+ // ---------------------------------------------------------------------------
18
+ // Rate limiter — simple in-memory per-IP sliding window (60 req/min)
19
+ // ---------------------------------------------------------------------------
20
+ const RATE_LIMIT_WINDOW_MS = 60_000;
21
+ const RATE_LIMIT_MAX = 60;
22
+ function createRateLimiter() {
23
+ const hits = new Map();
24
+ return (ip) => {
25
+ const now = Date.now();
26
+ const timestamps = hits.get(ip) ?? [];
27
+ const recent = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
28
+ if (recent.length >= RATE_LIMIT_MAX) {
29
+ hits.set(ip, recent);
30
+ return false; // rate limited
31
+ }
32
+ recent.push(now);
33
+ hits.set(ip, recent);
34
+ return true; // allowed
35
+ };
36
+ }
37
+ function getClientIp(req) {
38
+ return (req.socket.remoteAddress ??
39
+ req.headers["x-forwarded-for"]?.toString() ??
40
+ "unknown");
41
+ }
42
+ // ---------------------------------------------------------------------------
43
+ // Shared state for watch mode
44
+ // ---------------------------------------------------------------------------
45
+ let watchController = null;
46
+ // ---------------------------------------------------------------------------
47
+ // Helpers
48
+ // ---------------------------------------------------------------------------
49
+ function buildRef(sheet, tab) {
50
+ return { spreadsheetId: sheet, sheetName: tab ?? "Sheet1" };
51
+ }
52
+ function ok(text) {
53
+ return { content: [{ type: "text", text }] };
54
+ }
55
+ function err(error) {
56
+ return {
57
+ content: [
58
+ {
59
+ type: "text",
60
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
61
+ },
62
+ ],
63
+ isError: true,
64
+ };
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // Shared Zod schema for action_config (used by add_action)
68
+ // ---------------------------------------------------------------------------
69
+ const actionConfigSchema = z
70
+ .object({
71
+ id: z.string().describe("Unique action identifier"),
72
+ type: z
73
+ .enum(["http", "transform", "exec", "waterfall"])
74
+ .describe("Action type"),
75
+ target: z.string().describe("Target column to write results to"),
76
+ when: z
77
+ .string()
78
+ .optional()
79
+ .describe("Condition expression for when to run this action"),
80
+ method: z.string().optional().describe("HTTP method (GET, POST, etc.)"),
81
+ url: z.string().optional().describe("URL template for HTTP requests"),
82
+ headers: z
83
+ .record(z.string(), z.string())
84
+ .optional()
85
+ .describe("HTTP headers"),
86
+ body: z.any().optional().describe("HTTP request body"),
87
+ extract: z
88
+ .string()
89
+ .optional()
90
+ .describe("JSONPath or expression to extract from response"),
91
+ expression: z.string().optional().describe("Transform expression"),
92
+ command: z.string().optional().describe("Shell command to execute"),
93
+ timeout: z.number().optional().describe("Timeout in milliseconds"),
94
+ providers: z.array(z.any()).optional().describe("Waterfall providers list"),
95
+ onError: z
96
+ .record(z.string(), z.any())
97
+ .optional()
98
+ .describe("Error handling configuration"),
99
+ })
100
+ .passthrough();
101
+ const actionPatchSchema = z
102
+ .object({
103
+ id: z
104
+ .string()
105
+ .optional()
106
+ .describe("New action identifier (renames the action)"),
107
+ type: z
108
+ .enum(["http", "transform", "exec", "waterfall"])
109
+ .optional()
110
+ .describe("Action type"),
111
+ target: z.string().optional().describe("Target column to write results to"),
112
+ when: z
113
+ .string()
114
+ .optional()
115
+ .describe("Condition expression for when to run this action"),
116
+ method: z.string().optional().describe("HTTP method (GET, POST, etc.)"),
117
+ url: z.string().optional().describe("URL template for HTTP requests"),
118
+ headers: z
119
+ .record(z.string(), z.string())
120
+ .optional()
121
+ .describe("HTTP headers"),
122
+ body: z.any().optional().describe("HTTP request body"),
123
+ extract: z
124
+ .string()
125
+ .optional()
126
+ .describe("JSONPath or expression to extract from response"),
127
+ expression: z.string().optional().describe("Transform expression"),
128
+ command: z.string().optional().describe("Shell command to execute"),
129
+ timeout: z.number().optional().describe("Timeout in milliseconds"),
130
+ providers: z.array(z.any()).optional().describe("Waterfall providers list"),
131
+ onError: z
132
+ .record(z.string(), z.any())
133
+ .optional()
134
+ .describe("Error handling configuration"),
135
+ })
136
+ .passthrough();
137
+ // ---------------------------------------------------------------------------
138
+ // Server
139
+ // ---------------------------------------------------------------------------
140
+ const server = new McpServer({
141
+ name: "rowbound",
142
+ version: pkg.version,
143
+ });
144
+ // Shared adapter instance — enables header cache reuse across MCP calls
145
+ const adapter = new SheetsAdapter();
146
+ // ---------------------------------------------------------------------------
147
+ // 1. init_pipeline
148
+ // ---------------------------------------------------------------------------
149
+ server.registerTool("init_pipeline", {
150
+ description: "Initialize a Google Sheet with a default Rowbound pipeline config stored in Developer Metadata.",
151
+ inputSchema: z.object({
152
+ sheet: z.string().describe("Google Sheets spreadsheet ID"),
153
+ tab: z.string().optional().describe("Sheet tab name (default: Sheet1)"),
154
+ }),
155
+ }, async ({ sheet, tab }) => {
156
+ try {
157
+ const ref = buildRef(sheet, tab);
158
+ const existing = await adapter.readConfig(ref);
159
+ if (existing) {
160
+ return err("Config already exists for this sheet. Remove it first or use get_config to inspect.");
161
+ }
162
+ const tabName = tab ?? "Sheet1";
163
+ const sheets = await adapter.listSheets(sheet);
164
+ const targetSheet = sheets.find((s) => s.name === tabName);
165
+ if (!targetSheet) {
166
+ return err(`Tab "${tabName}" not found. Available: ${sheets.map((s) => s.name).join(", ")}`);
167
+ }
168
+ const gid = String(targetSheet.gid);
169
+ const defaultConfig = {
170
+ version: "2",
171
+ tabs: {
172
+ [gid]: {
173
+ name: tabName,
174
+ columns: {},
175
+ actions: [],
176
+ },
177
+ },
178
+ actions: [],
179
+ settings: defaultSettings,
180
+ };
181
+ await adapter.writeConfig(ref, defaultConfig);
182
+ return ok(`Initialized Rowbound config for sheet ${sheet} (tab: ${tabName}, GID: ${gid}).`);
183
+ }
184
+ catch (error) {
185
+ return err(error);
186
+ }
187
+ });
188
+ // ---------------------------------------------------------------------------
189
+ // 2. run_pipeline
190
+ // ---------------------------------------------------------------------------
191
+ server.registerTool("run_pipeline", {
192
+ description: "Run the enrichment pipeline on a Google Sheet. Returns a summary of rows processed, updates made, and errors.",
193
+ inputSchema: z.object({
194
+ sheet: z.string().describe("Google Sheets spreadsheet ID"),
195
+ tab: z.string().optional().describe("Sheet tab name"),
196
+ rows: z.string().optional().describe("Row range to process, e.g. '2-50'"),
197
+ action: z
198
+ .string()
199
+ .optional()
200
+ .describe("Run only a specific action by ID"),
201
+ dry: z
202
+ .boolean()
203
+ .optional()
204
+ .describe("Dry run — compute but do not write back"),
205
+ }),
206
+ }, async ({ sheet, tab, rows, action, dry }) => {
207
+ try {
208
+ const ref = buildRef(sheet, tab);
209
+ const config = await adapter.readConfig(ref);
210
+ if (!config) {
211
+ return err("No Rowbound config found. Run init_pipeline first.");
212
+ }
213
+ const reconciled = await reconcile(adapter, ref, config);
214
+ if (reconciled.configChanged) {
215
+ await adapter.writeConfig(ref, reconciled.config);
216
+ }
217
+ const tabConfig = reconciled.tabConfig;
218
+ if (tabConfig.actions.length === 0) {
219
+ return err("No actions configured. Add actions with add_action first.");
220
+ }
221
+ if (rows && !/^\d+-\d+$/.test(rows)) {
222
+ return err("Invalid rows format. Expected e.g. '2-50'.");
223
+ }
224
+ if (action && !tabConfig.actions.some((s) => s.id === action)) {
225
+ return err(`Action "${action}" not found. Available: ${tabConfig.actions.map((s) => s.id).join(", ")}`);
226
+ }
227
+ const range = rows ? rows.replace("-", ":") : undefined;
228
+ const resolvedConfig = {
229
+ ...reconciled.config,
230
+ actions: tabConfig.actions,
231
+ };
232
+ const env = buildSafeEnv(resolvedConfig);
233
+ const result = await runPipeline({
234
+ adapter,
235
+ ref,
236
+ config: resolvedConfig,
237
+ env,
238
+ range,
239
+ actionFilter: action,
240
+ dryRun: dry ?? false,
241
+ columnMap: tabConfig.columns,
242
+ });
243
+ const output = { ...result };
244
+ if (reconciled.messages.length > 0) {
245
+ output.columnMessages = reconciled.messages;
246
+ }
247
+ return ok(JSON.stringify(output, null, 2));
248
+ }
249
+ catch (error) {
250
+ return err(error);
251
+ }
252
+ });
253
+ // ---------------------------------------------------------------------------
254
+ // 3. add_action
255
+ // ---------------------------------------------------------------------------
256
+ server.registerTool("add_action", {
257
+ description: "Add an action to the pipeline config. Provide the action definition as a structured object.",
258
+ inputSchema: z.object({
259
+ sheet: z.string().describe("Google Sheets spreadsheet ID"),
260
+ tab: z.string().optional().describe("Sheet tab name"),
261
+ action_config: actionConfigSchema.describe("Action definition (must include id, type, and target)"),
262
+ }),
263
+ }, async ({ sheet, tab, action_config }) => {
264
+ try {
265
+ const ref = buildRef(sheet, tab);
266
+ const action = action_config;
267
+ const existing = await adapter.readConfig(ref);
268
+ if (!existing) {
269
+ return err("No Rowbound config found. Run init_pipeline first.");
270
+ }
271
+ if (existing.tabs) {
272
+ const { gid, tab: tabCfg } = getTabConfig(existing, tab);
273
+ if (tabCfg.actions.some((s) => s.id === action.id)) {
274
+ return err(`Action with id "${action.id}" already exists.`);
275
+ }
276
+ tabCfg.actions.push(action);
277
+ existing.tabs[gid] = tabCfg;
278
+ }
279
+ else {
280
+ if (existing.actions.some((s) => s.id === action.id)) {
281
+ return err(`Action with id "${action.id}" already exists.`);
282
+ }
283
+ existing.actions.push(action);
284
+ }
285
+ await adapter.writeConfig(ref, existing);
286
+ // Validate config and include warnings if any
287
+ const validation = validateConfig(existing);
288
+ const warnings = [...validation.errors, ...validation.warnings];
289
+ if (warnings.length > 0) {
290
+ return ok(`Added action "${action.id}" (${action.type} -> ${action.target}).\n\nValidation warnings:\n${warnings.map((w) => `- ${w}`).join("\n")}`);
291
+ }
292
+ return ok(`Added action "${action.id}" (${action.type} -> ${action.target}).`);
293
+ }
294
+ catch (error) {
295
+ return err(error);
296
+ }
297
+ });
298
+ // ---------------------------------------------------------------------------
299
+ // 4. remove_action
300
+ // ---------------------------------------------------------------------------
301
+ server.registerTool("remove_action", {
302
+ description: "Remove an action from the pipeline config by its ID.",
303
+ inputSchema: z.object({
304
+ sheet: z.string().describe("Google Sheets spreadsheet ID"),
305
+ tab: z.string().optional().describe("Sheet tab name"),
306
+ action_id: z.string().describe("ID of the action to remove"),
307
+ }),
308
+ }, async ({ sheet, tab, action_id }) => {
309
+ try {
310
+ const ref = buildRef(sheet, tab);
311
+ const existing = await adapter.readConfig(ref);
312
+ if (!existing) {
313
+ return err("No Rowbound config found. Run init_pipeline first.");
314
+ }
315
+ if (existing.tabs) {
316
+ const { gid, tab: tabCfg } = getTabConfig(existing, tab);
317
+ const originalLength = tabCfg.actions.length;
318
+ tabCfg.actions = tabCfg.actions.filter((s) => s.id !== action_id);
319
+ if (tabCfg.actions.length === originalLength) {
320
+ return err(`Action "${action_id}" not found in config.`);
321
+ }
322
+ existing.tabs[gid] = tabCfg;
323
+ }
324
+ else {
325
+ const originalLength = existing.actions.length;
326
+ existing.actions = existing.actions.filter((s) => s.id !== action_id);
327
+ if (existing.actions.length === originalLength) {
328
+ return err(`Action "${action_id}" not found in config.`);
329
+ }
330
+ }
331
+ await adapter.writeConfig(ref, existing);
332
+ return ok(`Removed action "${action_id}".`);
333
+ }
334
+ catch (error) {
335
+ return err(error);
336
+ }
337
+ });
338
+ // ---------------------------------------------------------------------------
339
+ // 5. update_action
340
+ // ---------------------------------------------------------------------------
341
+ server.registerTool("update_action", {
342
+ description: "Update an existing action by merging a partial definition. Can rename IDs, change targets, expressions, etc.",
343
+ inputSchema: z.object({
344
+ sheet: z.string().describe("Google Sheets spreadsheet ID"),
345
+ tab: z.string().optional().describe("Sheet tab name"),
346
+ action_id: z.string().describe("ID of the action to update"),
347
+ patch: actionPatchSchema.describe("Partial action definition to merge into the existing action"),
348
+ }),
349
+ }, async ({ sheet, tab, action_id, patch }) => {
350
+ try {
351
+ const ref = buildRef(sheet, tab);
352
+ const existing = await adapter.readConfig(ref);
353
+ if (!existing) {
354
+ return err("No Rowbound config found. Run init_pipeline first.");
355
+ }
356
+ let actions;
357
+ let gid;
358
+ if (existing.tabs) {
359
+ const resolved = getTabConfig(existing, tab);
360
+ gid = resolved.gid;
361
+ actions = resolved.tab.actions;
362
+ }
363
+ else {
364
+ actions = existing.actions;
365
+ }
366
+ const actionIndex = actions.findIndex((s) => s.id === action_id);
367
+ if (actionIndex === -1) {
368
+ return err(`Action "${action_id}" not found in config.`);
369
+ }
370
+ if (patch.id && patch.id !== action_id) {
371
+ if (actions.some((s) => s.id === patch.id)) {
372
+ return err(`Action with id "${patch.id}" already exists.`);
373
+ }
374
+ }
375
+ actions[actionIndex] = { ...actions[actionIndex], ...patch };
376
+ if (existing.tabs && gid) {
377
+ existing.tabs[gid].actions = actions;
378
+ }
379
+ else {
380
+ existing.actions = actions;
381
+ }
382
+ await adapter.writeConfig(ref, existing);
383
+ // Validate config and include warnings if any
384
+ const validation = validateConfig(existing);
385
+ const validationWarnings = [...validation.errors, ...validation.warnings];
386
+ const msg = `Updated action "${action_id}"${patch.id && patch.id !== action_id ? ` → "${patch.id}"` : ""}.`;
387
+ if (validationWarnings.length > 0) {
388
+ return ok(`${msg}\n\nValidation warnings:\n${validationWarnings.map((w) => `- ${w}`).join("\n")}`);
389
+ }
390
+ return ok(msg);
391
+ }
392
+ catch (error) {
393
+ return err(error);
394
+ }
395
+ });
396
+ // ---------------------------------------------------------------------------
397
+ // 6. update_settings
398
+ // ---------------------------------------------------------------------------
399
+ server.registerTool("update_settings", {
400
+ description: "Update pipeline settings (concurrency, rate limit, retry attempts, retry backoff).",
401
+ inputSchema: z.object({
402
+ sheet: z.string().describe("Google Sheets spreadsheet ID"),
403
+ tab: z.string().optional().describe("Sheet tab name"),
404
+ concurrency: z
405
+ .number()
406
+ .int()
407
+ .positive()
408
+ .optional()
409
+ .describe("Max concurrent rows"),
410
+ rate_limit: z
411
+ .number()
412
+ .int()
413
+ .positive()
414
+ .optional()
415
+ .describe("Max requests per second"),
416
+ retry_attempts: z
417
+ .number()
418
+ .int()
419
+ .nonnegative()
420
+ .optional()
421
+ .describe("Number of retry attempts"),
422
+ retry_backoff: z
423
+ .enum(["exponential", "linear", "fixed"])
424
+ .optional()
425
+ .describe("Backoff strategy (exponential, linear, fixed)"),
426
+ }),
427
+ }, async ({ sheet, tab, concurrency, rate_limit, retry_attempts, retry_backoff, }) => {
428
+ try {
429
+ const ref = buildRef(sheet, tab);
430
+ const existing = await adapter.readConfig(ref);
431
+ if (!existing) {
432
+ return err("No Rowbound config found. Run init_pipeline first.");
433
+ }
434
+ const changes = [];
435
+ if (concurrency !== undefined) {
436
+ existing.settings.concurrency = concurrency;
437
+ changes.push(`concurrency=${concurrency}`);
438
+ }
439
+ if (rate_limit !== undefined) {
440
+ existing.settings.rateLimit = rate_limit;
441
+ changes.push(`rateLimit=${rate_limit}`);
442
+ }
443
+ if (retry_attempts !== undefined) {
444
+ existing.settings.retryAttempts = retry_attempts;
445
+ changes.push(`retryAttempts=${retry_attempts}`);
446
+ }
447
+ if (retry_backoff !== undefined) {
448
+ existing.settings.retryBackoff = retry_backoff;
449
+ changes.push(`retryBackoff=${retry_backoff}`);
450
+ }
451
+ if (changes.length === 0) {
452
+ return err("No settings provided. Specify at least one setting to update.");
453
+ }
454
+ await adapter.writeConfig(ref, existing);
455
+ return ok(`Updated settings: ${changes.join(", ")}`);
456
+ }
457
+ catch (error) {
458
+ return err(error);
459
+ }
460
+ });
461
+ // ---------------------------------------------------------------------------
462
+ // 7. sync_columns
463
+ // ---------------------------------------------------------------------------
464
+ server.registerTool("sync_columns", {
465
+ description: "Sync the column registry with the current sheet state — reconcile renames, track new columns, remove deleted ones, and migrate action targets to IDs.",
466
+ inputSchema: z.object({
467
+ sheet: z.string().describe("Google Sheets spreadsheet ID"),
468
+ tab: z.string().optional().describe("Sheet tab name"),
469
+ }),
470
+ }, async ({ sheet, tab }) => {
471
+ try {
472
+ const ref = buildRef(sheet, tab);
473
+ const config = await adapter.readConfig(ref);
474
+ if (!config) {
475
+ return err("No Rowbound config found. Run init_pipeline first.");
476
+ }
477
+ const reconciled = await reconcile(adapter, ref, config);
478
+ if (reconciled.configChanged) {
479
+ await adapter.writeConfig(ref, reconciled.config);
480
+ }
481
+ const tabConfig = reconciled.tabConfig;
482
+ const cols = Object.keys(tabConfig.columns).length;
483
+ const actions = tabConfig.actions.length;
484
+ const output = {
485
+ columnsTracked: cols,
486
+ actionsConfigured: actions,
487
+ tabGid: reconciled.tabGid,
488
+ tabName: tabConfig.name,
489
+ changed: reconciled.configChanged,
490
+ };
491
+ if (reconciled.messages.length > 0) {
492
+ output.messages = reconciled.messages;
493
+ }
494
+ return ok(JSON.stringify(output, null, 2));
495
+ }
496
+ catch (error) {
497
+ return err(error);
498
+ }
499
+ });
500
+ // ---------------------------------------------------------------------------
501
+ // 8. get_config
502
+ // ---------------------------------------------------------------------------
503
+ server.registerTool("get_config", {
504
+ description: "Return the current pipeline config as formatted JSON.",
505
+ inputSchema: z.object({
506
+ sheet: z.string().describe("Google Sheets spreadsheet ID"),
507
+ tab: z.string().optional().describe("Sheet tab name"),
508
+ }),
509
+ }, async ({ sheet, tab }) => {
510
+ try {
511
+ const ref = buildRef(sheet, tab);
512
+ const config = await adapter.readConfig(ref);
513
+ if (!config) {
514
+ return err("No Rowbound config found for this sheet.");
515
+ }
516
+ return ok(JSON.stringify(config, null, 2));
517
+ }
518
+ catch (error) {
519
+ return err(error);
520
+ }
521
+ });
522
+ // ---------------------------------------------------------------------------
523
+ // 9. validate_config
524
+ // ---------------------------------------------------------------------------
525
+ server.registerTool("validate_config", {
526
+ description: "Validate the pipeline config and return validation results.",
527
+ inputSchema: z.object({
528
+ sheet: z.string().describe("Google Sheets spreadsheet ID"),
529
+ tab: z.string().optional().describe("Sheet tab name"),
530
+ }),
531
+ }, async ({ sheet, tab }) => {
532
+ try {
533
+ const ref = buildRef(sheet, tab);
534
+ const config = await adapter.readConfig(ref);
535
+ if (!config) {
536
+ return err("No Rowbound config found for this sheet.");
537
+ }
538
+ let validationConfig = config;
539
+ let actionCount = config.actions.length;
540
+ if (config.tabs) {
541
+ const { tab: tabCfg } = getTabConfig(config, tab);
542
+ validationConfig = { ...config, actions: tabCfg.actions };
543
+ actionCount = tabCfg.actions.length;
544
+ }
545
+ const result = validateConfig(validationConfig);
546
+ if (result.valid) {
547
+ return ok(JSON.stringify({
548
+ valid: true,
549
+ version: config.version,
550
+ actionCount,
551
+ settings: config.settings,
552
+ warnings: result.warnings.length > 0 ? result.warnings : undefined,
553
+ }, null, 2));
554
+ }
555
+ else {
556
+ return ok(JSON.stringify({
557
+ valid: false,
558
+ errors: result.errors,
559
+ warnings: result.warnings.length > 0 ? result.warnings : undefined,
560
+ }, null, 2));
561
+ }
562
+ }
563
+ catch (error) {
564
+ return err(error);
565
+ }
566
+ });
567
+ // ---------------------------------------------------------------------------
568
+ // 10. get_status
569
+ // ---------------------------------------------------------------------------
570
+ server.registerTool("get_status", {
571
+ description: "Return pipeline status: action count, settings, and enrichment rates per target column.",
572
+ inputSchema: z.object({
573
+ sheet: z.string().describe("Google Sheets spreadsheet ID"),
574
+ tab: z.string().optional().describe("Sheet tab name"),
575
+ }),
576
+ }, async ({ sheet, tab }) => {
577
+ try {
578
+ const ref = buildRef(sheet, tab);
579
+ const config = await adapter.readConfig(ref);
580
+ if (!config) {
581
+ return err("No Rowbound config found. Run init_pipeline first.");
582
+ }
583
+ let actions;
584
+ if (config.tabs) {
585
+ const { tab: tabCfg } = getTabConfig(config, tab);
586
+ actions = tabCfg.actions;
587
+ }
588
+ else {
589
+ actions = config.actions;
590
+ }
591
+ const status = {
592
+ actions: actions.map((s) => ({
593
+ id: s.id,
594
+ type: s.type,
595
+ target: s.target,
596
+ })),
597
+ settings: config.settings,
598
+ };
599
+ try {
600
+ const rows = await adapter.readRows(ref);
601
+ const targetColumns = [...new Set(actions.map((s) => s.target))];
602
+ status.totalRows = rows.length;
603
+ status.enrichment = targetColumns.map((target) => {
604
+ const filled = rows.filter((row) => row[target] !== undefined && row[target] !== "").length;
605
+ const pct = rows.length > 0 ? Math.round((filled / rows.length) * 100) : 0;
606
+ return { column: target, filled, total: rows.length, percent: pct };
607
+ });
608
+ }
609
+ catch {
610
+ status.dataError = "Could not read sheet data for enrichment status.";
611
+ }
612
+ return ok(JSON.stringify(status, null, 2));
613
+ }
614
+ catch (error) {
615
+ return err(error);
616
+ }
617
+ });
618
+ // ---------------------------------------------------------------------------
619
+ // 11. dry_run
620
+ // ---------------------------------------------------------------------------
621
+ server.registerTool("dry_run", {
622
+ description: "Run the pipeline in dry mode — compute what would be changed without writing back to the sheet.",
623
+ inputSchema: z.object({
624
+ sheet: z.string().describe("Google Sheets spreadsheet ID"),
625
+ tab: z.string().optional().describe("Sheet tab name"),
626
+ rows: z.string().optional().describe("Row range to process, e.g. '2-50'"),
627
+ }),
628
+ }, async ({ sheet, tab, rows }) => {
629
+ try {
630
+ const ref = buildRef(sheet, tab);
631
+ const config = await adapter.readConfig(ref);
632
+ if (!config) {
633
+ return err("No Rowbound config found. Run init_pipeline first.");
634
+ }
635
+ const reconciled = await reconcile(adapter, ref, config);
636
+ const tabConfig = reconciled.tabConfig;
637
+ if (tabConfig.actions.length === 0) {
638
+ return err("No actions configured. Add actions with add_action first.");
639
+ }
640
+ if (rows && !/^\d+-\d+$/.test(rows)) {
641
+ return err("Invalid rows format. Expected e.g. '2-50'.");
642
+ }
643
+ const range = rows ? rows.replace("-", ":") : undefined;
644
+ const resolvedConfig = {
645
+ ...reconciled.config,
646
+ actions: tabConfig.actions,
647
+ };
648
+ const env = buildSafeEnv(resolvedConfig);
649
+ const result = await runPipeline({
650
+ adapter,
651
+ ref,
652
+ config: resolvedConfig,
653
+ env,
654
+ range,
655
+ dryRun: true,
656
+ columnMap: tabConfig.columns,
657
+ });
658
+ const output = { dryRun: true, ...result };
659
+ if (reconciled.messages.length > 0) {
660
+ output.columnMessages = reconciled.messages;
661
+ }
662
+ return ok(JSON.stringify(output, null, 2));
663
+ }
664
+ catch (error) {
665
+ return err(error);
666
+ }
667
+ });
668
+ // ---------------------------------------------------------------------------
669
+ // 12. start_watch
670
+ // ---------------------------------------------------------------------------
671
+ server.registerTool("start_watch", {
672
+ description: "Start watch mode — poll the sheet on an interval and optionally run a webhook server. This blocks the tool call until stopped.",
673
+ inputSchema: z.object({
674
+ sheet: z.string().describe("Google Sheets spreadsheet ID"),
675
+ tab: z.string().optional().describe("Sheet tab name"),
676
+ interval: z
677
+ .number()
678
+ .optional()
679
+ .describe("Polling interval in seconds (default: 30)"),
680
+ port: z
681
+ .number()
682
+ .optional()
683
+ .describe("Webhook server port (default: 3000)"),
684
+ }),
685
+ }, async ({ sheet, tab, interval, port }) => {
686
+ try {
687
+ if (watchController) {
688
+ return err("Watch mode is already running. Call stop_watch first.");
689
+ }
690
+ const ref = buildRef(sheet, tab);
691
+ const config = await adapter.readConfig(ref);
692
+ if (!config) {
693
+ return err("No Rowbound config found. Run init_pipeline first.");
694
+ }
695
+ const hasActions = config.tabs
696
+ ? Object.values(config.tabs).some((t) => t.actions.length > 0)
697
+ : config.actions.length > 0;
698
+ if (!hasActions) {
699
+ return err("No actions configured. Add actions with add_action first.");
700
+ }
701
+ const intervalSeconds = interval ?? 30;
702
+ const webhookPort = port ?? 3000;
703
+ const webhookToken = process.env.ROWBOUND_WEBHOOK_TOKEN;
704
+ watchController = new AbortController();
705
+ const controller = watchController;
706
+ let isRunning = false;
707
+ async function runOnce() {
708
+ if (isRunning || controller.signal.aborted)
709
+ return;
710
+ isRunning = true;
711
+ try {
712
+ const freshConfig = await adapter.readConfig(ref);
713
+ const activeConfig = freshConfig ?? config;
714
+ const env = buildSafeEnv(activeConfig);
715
+ const reconciled = await reconcile(adapter, ref, activeConfig);
716
+ if (reconciled.configChanged) {
717
+ await adapter.writeConfig(ref, reconciled.config);
718
+ }
719
+ const tabCfg = reconciled.tabConfig;
720
+ const resolvedConfig = {
721
+ ...reconciled.config,
722
+ actions: tabCfg.actions,
723
+ };
724
+ await runPipeline({
725
+ adapter,
726
+ ref,
727
+ config: resolvedConfig,
728
+ env,
729
+ signal: controller.signal,
730
+ columnMap: tabCfg.columns,
731
+ });
732
+ }
733
+ finally {
734
+ isRunning = false;
735
+ }
736
+ }
737
+ // Immediate first run (matches CLI watch behavior)
738
+ try {
739
+ await runOnce();
740
+ }
741
+ catch {
742
+ // Don't prevent the interval from starting
743
+ }
744
+ // Start polling loop
745
+ const intervalId = setInterval(async () => {
746
+ if (controller.signal.aborted)
747
+ return;
748
+ try {
749
+ await runOnce();
750
+ }
751
+ catch (error) {
752
+ console.error(`[watch] Poll error: ${error instanceof Error ? error.message : String(error)}`);
753
+ }
754
+ }, intervalSeconds * 1000);
755
+ // Start webhook server
756
+ const { createServer } = await import("node:http");
757
+ const isAllowed = createRateLimiter();
758
+ const httpServer = createServer(async (req, res) => {
759
+ const ip = getClientIp(req);
760
+ if (!isAllowed(ip)) {
761
+ res.writeHead(429, { "Content-Type": "application/json" });
762
+ res.end(JSON.stringify({ error: "Too Many Requests" }));
763
+ return;
764
+ }
765
+ if (req.method !== "POST" || req.url !== "/webhook") {
766
+ res.writeHead(404, { "Content-Type": "application/json" });
767
+ res.end(JSON.stringify({ error: "Not found" }));
768
+ return;
769
+ }
770
+ if (webhookToken) {
771
+ const authHeader = req.headers.authorization ?? "";
772
+ if (!safeCompare(authHeader, `Bearer ${webhookToken}`)) {
773
+ res.writeHead(401, { "Content-Type": "application/json" });
774
+ res.end(JSON.stringify({ error: "Unauthorized" }));
775
+ return;
776
+ }
777
+ }
778
+ const chunks = [];
779
+ let totalBytes = 0;
780
+ for await (const chunk of req) {
781
+ totalBytes += chunk.length;
782
+ if (totalBytes > 1_048_576) {
783
+ res.writeHead(413, { "Content-Type": "application/json" });
784
+ res.end(JSON.stringify({ error: "Payload too large" }));
785
+ return;
786
+ }
787
+ chunks.push(chunk);
788
+ }
789
+ try {
790
+ await runOnce();
791
+ res.writeHead(200, { "Content-Type": "application/json" });
792
+ res.end(JSON.stringify({ ok: true }));
793
+ }
794
+ catch (e) {
795
+ const msg = e instanceof Error ? e.message : String(e);
796
+ res.writeHead(500, { "Content-Type": "application/json" });
797
+ res.end(JSON.stringify({ error: msg }));
798
+ }
799
+ });
800
+ httpServer.headersTimeout = 10_000;
801
+ httpServer.requestTimeout = 30_000;
802
+ httpServer.keepAliveTimeout = 5_000;
803
+ httpServer.listen(webhookPort, "127.0.0.1");
804
+ // Wait until aborted
805
+ try {
806
+ await new Promise((resolve) => {
807
+ controller.signal.addEventListener("abort", () => {
808
+ clearInterval(intervalId);
809
+ httpServer.close();
810
+ resolve();
811
+ });
812
+ });
813
+ }
814
+ finally {
815
+ watchController = null;
816
+ }
817
+ return ok(`Watch mode stopped. Was polling sheet ${sheet} every ${intervalSeconds}s with webhook on port ${webhookPort}.`);
818
+ }
819
+ catch (error) {
820
+ watchController = null;
821
+ return err(error);
822
+ }
823
+ });
824
+ // ---------------------------------------------------------------------------
825
+ // 13. stop_watch
826
+ // ---------------------------------------------------------------------------
827
+ server.registerTool("stop_watch", {
828
+ description: "Stop watch mode if it is currently running.",
829
+ inputSchema: z.object({}),
830
+ }, async () => {
831
+ try {
832
+ if (!watchController) {
833
+ return ok("Watch mode is not running.");
834
+ }
835
+ watchController.abort();
836
+ return ok("Watch mode stopped.");
837
+ }
838
+ catch (error) {
839
+ return err(error);
840
+ }
841
+ });
842
+ // ---------------------------------------------------------------------------
843
+ // 14. preview_rows
844
+ // ---------------------------------------------------------------------------
845
+ server.registerTool("preview_rows", {
846
+ description: "Read rows from the sheet and return them as formatted text. Useful for inspecting data before running the pipeline.",
847
+ inputSchema: z.object({
848
+ sheet: z.string().describe("Google Sheets spreadsheet ID"),
849
+ tab: z.string().optional().describe("Sheet tab name"),
850
+ range: z
851
+ .string()
852
+ .optional()
853
+ .describe('Sheet range to read (e.g. "A1:D10")'),
854
+ limit: z
855
+ .number()
856
+ .optional()
857
+ .describe("Maximum number of data rows to return (default: 10)"),
858
+ }),
859
+ }, async ({ sheet, tab, range, limit }) => {
860
+ try {
861
+ const ref = { spreadsheetId: sheet, sheetName: tab };
862
+ const rows = range
863
+ ? await adapter.readRows(ref, range)
864
+ : await adapter.readRows(ref);
865
+ const maxRows = limit ?? 10;
866
+ const sliced = rows.slice(0, maxRows);
867
+ if (sliced.length === 0) {
868
+ return ok("No data rows found.");
869
+ }
870
+ const headers = Object.keys(sliced[0]);
871
+ const lines = [headers.join("\t")];
872
+ for (const row of sliced) {
873
+ lines.push(headers.map((h) => row[h] ?? "").join("\t"));
874
+ }
875
+ const summary = rows.length > maxRows
876
+ ? `\n\n(Showing ${maxRows} of ${rows.length} rows)`
877
+ : `\n\n(${rows.length} rows total)`;
878
+ return ok(lines.join("\n") + summary);
879
+ }
880
+ catch (error) {
881
+ return err(error);
882
+ }
883
+ });
884
+ // ---------------------------------------------------------------------------
885
+ // 15. list_runs
886
+ // ---------------------------------------------------------------------------
887
+ server.registerTool("list_runs", {
888
+ description: "List recent pipeline runs with status, duration, and error counts",
889
+ inputSchema: z.object({
890
+ sheet: z.string().optional().describe("Filter by Google Sheet ID"),
891
+ limit: z.number().optional().describe("Max runs to return (default 20)"),
892
+ }),
893
+ }, async ({ sheet, limit }) => {
894
+ try {
895
+ const runs = await listRuns({ sheetId: sheet, limit });
896
+ return ok(formatRunList(runs));
897
+ }
898
+ catch (error) {
899
+ return err(error);
900
+ }
901
+ });
902
+ // ---------------------------------------------------------------------------
903
+ // 16. get_run
904
+ // ---------------------------------------------------------------------------
905
+ server.registerTool("get_run", {
906
+ description: "Get detailed status of a specific pipeline run including action summaries and errors",
907
+ inputSchema: z.object({
908
+ run_id: z.string().optional().describe("Run ID to view"),
909
+ last: z.boolean().optional().describe("View the most recent run"),
910
+ errors_only: z.boolean().optional().describe("Show only errors"),
911
+ }),
912
+ }, async ({ run_id, last, errors_only }) => {
913
+ try {
914
+ let run;
915
+ if (run_id) {
916
+ run = await readRunState(run_id);
917
+ if (!run) {
918
+ return err(`Run "${run_id}" not found.`);
919
+ }
920
+ }
921
+ else if (last) {
922
+ const runs = await listRuns({ limit: 1 });
923
+ if (runs.length === 0) {
924
+ return err("No runs found.");
925
+ }
926
+ run = runs[0];
927
+ }
928
+ else {
929
+ return err("Provide either run_id or set last=true.");
930
+ }
931
+ return ok(formatRunDetail(run, errors_only ?? false));
932
+ }
933
+ catch (error) {
934
+ return err(error);
935
+ }
936
+ });
937
+ // ---------------------------------------------------------------------------
938
+ // Export startup function
939
+ // ---------------------------------------------------------------------------
940
+ export async function startMcpServer() {
941
+ const transport = new StdioServerTransport();
942
+ await server.connect(transport);
943
+ }