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.
- package/LICENSE +21 -0
- package/README.md +258 -0
- package/dist/adapters/adapter.d.ts +1 -0
- package/dist/adapters/adapter.js +1 -0
- package/dist/adapters/sheets/sheets-adapter.d.ts +66 -0
- package/dist/adapters/sheets/sheets-adapter.js +531 -0
- package/dist/cli/config.d.ts +2 -0
- package/dist/cli/config.js +397 -0
- package/dist/cli/env.d.ts +3 -0
- package/dist/cli/env.js +103 -0
- package/dist/cli/format.d.ts +5 -0
- package/dist/cli/format.js +6 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +39 -0
- package/dist/cli/init.d.ts +10 -0
- package/dist/cli/init.js +72 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.js +212 -0
- package/dist/cli/runs.d.ts +2 -0
- package/dist/cli/runs.js +108 -0
- package/dist/cli/status.d.ts +2 -0
- package/dist/cli/status.js +108 -0
- package/dist/cli/sync.d.ts +2 -0
- package/dist/cli/sync.js +84 -0
- package/dist/cli/watch.d.ts +2 -0
- package/dist/cli/watch.js +348 -0
- package/dist/core/condition.d.ts +25 -0
- package/dist/core/condition.js +66 -0
- package/dist/core/defaults.d.ts +3 -0
- package/dist/core/defaults.js +7 -0
- package/dist/core/engine.d.ts +50 -0
- package/dist/core/engine.js +234 -0
- package/dist/core/env.d.ts +13 -0
- package/dist/core/env.js +72 -0
- package/dist/core/exec.d.ts +24 -0
- package/dist/core/exec.js +134 -0
- package/dist/core/extractor.d.ts +10 -0
- package/dist/core/extractor.js +33 -0
- package/dist/core/http-client.d.ts +32 -0
- package/dist/core/http-client.js +161 -0
- package/dist/core/rate-limiter.d.ts +25 -0
- package/dist/core/rate-limiter.js +64 -0
- package/dist/core/reconcile.d.ts +24 -0
- package/dist/core/reconcile.js +192 -0
- package/dist/core/run-format.d.ts +39 -0
- package/dist/core/run-format.js +201 -0
- package/dist/core/run-state.d.ts +64 -0
- package/dist/core/run-state.js +141 -0
- package/dist/core/run-tracker.d.ts +15 -0
- package/dist/core/run-tracker.js +57 -0
- package/dist/core/safe-compare.d.ts +8 -0
- package/dist/core/safe-compare.js +19 -0
- package/dist/core/shell-escape.d.ts +7 -0
- package/dist/core/shell-escape.js +9 -0
- package/dist/core/tab-resolver.d.ts +17 -0
- package/dist/core/tab-resolver.js +44 -0
- package/dist/core/template.d.ts +32 -0
- package/dist/core/template.js +82 -0
- package/dist/core/types.d.ts +105 -0
- package/dist/core/types.js +2 -0
- package/dist/core/url-guard.d.ts +21 -0
- package/dist/core/url-guard.js +184 -0
- package/dist/core/validator.d.ts +11 -0
- package/dist/core/validator.js +261 -0
- package/dist/core/waterfall.d.ts +26 -0
- package/dist/core/waterfall.js +55 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +16 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +943 -0
- 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
|
+
}
|