@getjack/jack 0.1.16 → 0.1.17
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 +1 -1
- package/package.json +5 -2
- package/src/commands/community.ts +47 -0
- package/src/commands/services.ts +269 -5
- package/src/index.ts +7 -0
- package/src/lib/hooks.ts +20 -0
- package/src/lib/project-operations.ts +278 -31
- package/src/lib/services/db-execute.ts +485 -0
- package/src/lib/services/sql-classifier.test.ts +404 -0
- package/src/lib/services/sql-classifier.ts +346 -0
- package/src/lib/storage/file-filter.ts +4 -0
- package/src/lib/telemetry.ts +3 -0
- package/src/lib/wrangler-config.test.ts +322 -0
- package/src/lib/wrangler-config.ts +459 -0
- package/src/mcp/tools/index.ts +161 -0
- package/src/templates/index.ts +4 -0
- package/src/templates/types.ts +12 -0
- package/templates/api/AGENTS.md +33 -0
- package/templates/hello/AGENTS.md +33 -0
- package/templates/miniapp/.jack.json +4 -5
- package/templates/miniapp/AGENTS.md +33 -0
- package/templates/nextjs/AGENTS.md +33 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for modifying wrangler.jsonc while preserving comments
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { parseJsonc } from "./jsonc.ts";
|
|
7
|
+
|
|
8
|
+
export interface D1BindingConfig {
|
|
9
|
+
binding: string; // e.g., "DB" or "ANALYTICS_DB"
|
|
10
|
+
database_name: string; // e.g., "my-app-db"
|
|
11
|
+
database_id: string; // UUID from Cloudflare
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface WranglerConfig {
|
|
15
|
+
d1_databases?: Array<{
|
|
16
|
+
binding: string;
|
|
17
|
+
database_name?: string;
|
|
18
|
+
database_id?: string;
|
|
19
|
+
}>;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get existing D1 bindings from wrangler.jsonc
|
|
25
|
+
*/
|
|
26
|
+
export async function getExistingD1Bindings(configPath: string): Promise<D1BindingConfig[]> {
|
|
27
|
+
if (!existsSync(configPath)) {
|
|
28
|
+
throw new Error(`wrangler.jsonc not found at ${configPath}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const content = await Bun.file(configPath).text();
|
|
32
|
+
const config = parseJsonc<WranglerConfig>(content);
|
|
33
|
+
|
|
34
|
+
if (!config.d1_databases || !Array.isArray(config.d1_databases)) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return config.d1_databases
|
|
39
|
+
.filter((db) => db.binding && db.database_name && db.database_id)
|
|
40
|
+
.map((db) => ({
|
|
41
|
+
binding: db.binding,
|
|
42
|
+
database_name: db.database_name as string,
|
|
43
|
+
database_id: db.database_id as string,
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Add a D1 database binding to wrangler.jsonc while preserving comments.
|
|
49
|
+
*
|
|
50
|
+
* Uses text manipulation to preserve comments rather than full JSON parsing.
|
|
51
|
+
*/
|
|
52
|
+
export async function addD1Binding(configPath: string, binding: D1BindingConfig): Promise<void> {
|
|
53
|
+
if (!existsSync(configPath)) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`wrangler.jsonc not found at ${configPath}. Create a wrangler.jsonc file first or run 'jack new' to create a new project.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const content = await Bun.file(configPath).text();
|
|
60
|
+
|
|
61
|
+
// Parse to understand existing structure
|
|
62
|
+
const config = parseJsonc<WranglerConfig>(content);
|
|
63
|
+
|
|
64
|
+
// Format the new binding entry
|
|
65
|
+
const bindingJson = formatD1BindingEntry(binding);
|
|
66
|
+
|
|
67
|
+
let newContent: string;
|
|
68
|
+
|
|
69
|
+
if (config.d1_databases && Array.isArray(config.d1_databases)) {
|
|
70
|
+
// d1_databases exists - append to the array
|
|
71
|
+
newContent = appendToD1DatabasesArray(content, bindingJson);
|
|
72
|
+
} else {
|
|
73
|
+
// d1_databases doesn't exist - add it before closing brace
|
|
74
|
+
newContent = addD1DatabasesSection(content, bindingJson);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await Bun.write(configPath, newContent);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Format a D1 binding as a JSON object string with proper indentation
|
|
82
|
+
*/
|
|
83
|
+
function formatD1BindingEntry(binding: D1BindingConfig): string {
|
|
84
|
+
return `{
|
|
85
|
+
"binding": "${binding.binding}",
|
|
86
|
+
"database_name": "${binding.database_name}",
|
|
87
|
+
"database_id": "${binding.database_id}"
|
|
88
|
+
}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Append a new entry to an existing d1_databases array.
|
|
93
|
+
* Finds the closing bracket of the array and inserts before it.
|
|
94
|
+
*/
|
|
95
|
+
function appendToD1DatabasesArray(content: string, bindingJson: string): string {
|
|
96
|
+
// Find "d1_databases" and then find its closing bracket
|
|
97
|
+
const d1Match = content.match(/"d1_databases"\s*:\s*\[/);
|
|
98
|
+
if (!d1Match || d1Match.index === undefined) {
|
|
99
|
+
throw new Error("Could not find d1_databases array in config");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const arrayStartIndex = d1Match.index + d1Match[0].length;
|
|
103
|
+
|
|
104
|
+
// Find the matching closing bracket, accounting for nested structures
|
|
105
|
+
const closingBracketIndex = findMatchingBracket(content, arrayStartIndex - 1, "[", "]");
|
|
106
|
+
if (closingBracketIndex === -1) {
|
|
107
|
+
throw new Error("Could not find closing bracket for d1_databases array");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check if array is empty or has content
|
|
111
|
+
const arrayContent = content.slice(arrayStartIndex, closingBracketIndex).trim();
|
|
112
|
+
const isEmpty = arrayContent === "" || isOnlyCommentsAndWhitespace(arrayContent);
|
|
113
|
+
|
|
114
|
+
// Build the insertion
|
|
115
|
+
let insertion: string;
|
|
116
|
+
if (isEmpty) {
|
|
117
|
+
// Empty array - just add the entry
|
|
118
|
+
insertion = `\n\t\t${bindingJson}\n\t`;
|
|
119
|
+
} else {
|
|
120
|
+
// Has existing entries - add comma and new entry
|
|
121
|
+
insertion = `,\n\t\t${bindingJson}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Find position just before the closing bracket
|
|
125
|
+
// We want to insert after the last non-whitespace content but before the bracket
|
|
126
|
+
const beforeBracket = content.slice(0, closingBracketIndex);
|
|
127
|
+
const afterBracket = content.slice(closingBracketIndex);
|
|
128
|
+
|
|
129
|
+
if (isEmpty) {
|
|
130
|
+
return beforeBracket + insertion + afterBracket;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// For non-empty arrays, find the last closing brace of an object in the array
|
|
134
|
+
const lastObjectEnd = findLastObjectEndInArray(content, arrayStartIndex, closingBracketIndex);
|
|
135
|
+
if (lastObjectEnd === -1) {
|
|
136
|
+
// Fallback: insert before closing bracket
|
|
137
|
+
return beforeBracket + insertion + afterBracket;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return content.slice(0, lastObjectEnd + 1) + insertion + content.slice(lastObjectEnd + 1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Add a new d1_databases section to the config.
|
|
145
|
+
* Inserts before the final closing brace.
|
|
146
|
+
*/
|
|
147
|
+
function addD1DatabasesSection(content: string, bindingJson: string): string {
|
|
148
|
+
// Find the last closing brace in the file
|
|
149
|
+
const lastBraceIndex = content.lastIndexOf("}");
|
|
150
|
+
if (lastBraceIndex === -1) {
|
|
151
|
+
throw new Error("Invalid JSON: no closing brace found");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Check what comes before the last brace to determine if we need a comma
|
|
155
|
+
const beforeBrace = content.slice(0, lastBraceIndex);
|
|
156
|
+
const needsComma = shouldAddCommaBefore(beforeBrace);
|
|
157
|
+
|
|
158
|
+
// Build the d1_databases section
|
|
159
|
+
const d1Section = `"d1_databases": [
|
|
160
|
+
${bindingJson}
|
|
161
|
+
]`;
|
|
162
|
+
|
|
163
|
+
// Find proper insertion point - look for last non-whitespace content
|
|
164
|
+
const trimmedBefore = beforeBrace.trimEnd();
|
|
165
|
+
const whitespaceAfterContent = beforeBrace.slice(trimmedBefore.length);
|
|
166
|
+
|
|
167
|
+
let insertion: string;
|
|
168
|
+
if (needsComma) {
|
|
169
|
+
insertion = `,\n\t${d1Section}`;
|
|
170
|
+
} else {
|
|
171
|
+
insertion = `\n\t${d1Section}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Reconstruct: content before + insertion + newline + closing brace
|
|
175
|
+
return trimmedBefore + insertion + "\n" + content.slice(lastBraceIndex);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Find the matching closing bracket/brace for an opening one
|
|
180
|
+
*/
|
|
181
|
+
function findMatchingBracket(
|
|
182
|
+
content: string,
|
|
183
|
+
startIndex: number,
|
|
184
|
+
openChar: string,
|
|
185
|
+
closeChar: string,
|
|
186
|
+
): number {
|
|
187
|
+
let depth = 0;
|
|
188
|
+
let inString = false;
|
|
189
|
+
let stringChar = "";
|
|
190
|
+
let escaped = false;
|
|
191
|
+
let inLineComment = false;
|
|
192
|
+
let inBlockComment = false;
|
|
193
|
+
|
|
194
|
+
for (let i = startIndex; i < content.length; i++) {
|
|
195
|
+
const char = content[i] ?? "";
|
|
196
|
+
const next = content[i + 1] ?? "";
|
|
197
|
+
|
|
198
|
+
// Handle line comments
|
|
199
|
+
if (inLineComment) {
|
|
200
|
+
if (char === "\n") {
|
|
201
|
+
inLineComment = false;
|
|
202
|
+
}
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Handle block comments
|
|
207
|
+
if (inBlockComment) {
|
|
208
|
+
if (char === "*" && next === "/") {
|
|
209
|
+
inBlockComment = false;
|
|
210
|
+
i++;
|
|
211
|
+
}
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Handle strings
|
|
216
|
+
if (inString) {
|
|
217
|
+
if (escaped) {
|
|
218
|
+
escaped = false;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (char === "\\") {
|
|
222
|
+
escaped = true;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (char === stringChar) {
|
|
226
|
+
inString = false;
|
|
227
|
+
stringChar = "";
|
|
228
|
+
}
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Check for comment start
|
|
233
|
+
if (char === "/" && next === "/") {
|
|
234
|
+
inLineComment = true;
|
|
235
|
+
i++;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (char === "/" && next === "*") {
|
|
239
|
+
inBlockComment = true;
|
|
240
|
+
i++;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Check for string start
|
|
245
|
+
if (char === '"' || char === "'") {
|
|
246
|
+
inString = true;
|
|
247
|
+
stringChar = char;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Track bracket depth
|
|
252
|
+
if (char === openChar) {
|
|
253
|
+
depth++;
|
|
254
|
+
} else if (char === closeChar) {
|
|
255
|
+
depth--;
|
|
256
|
+
if (depth === 0) {
|
|
257
|
+
return i;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return -1;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Check if content is only whitespace and comments
|
|
267
|
+
*/
|
|
268
|
+
function isOnlyCommentsAndWhitespace(content: string): boolean {
|
|
269
|
+
let inLineComment = false;
|
|
270
|
+
let inBlockComment = false;
|
|
271
|
+
|
|
272
|
+
for (let i = 0; i < content.length; i++) {
|
|
273
|
+
const char = content[i] ?? "";
|
|
274
|
+
const next = content[i + 1] ?? "";
|
|
275
|
+
|
|
276
|
+
if (inLineComment) {
|
|
277
|
+
if (char === "\n") {
|
|
278
|
+
inLineComment = false;
|
|
279
|
+
}
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (inBlockComment) {
|
|
284
|
+
if (char === "*" && next === "/") {
|
|
285
|
+
inBlockComment = false;
|
|
286
|
+
i++;
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (char === "/" && next === "/") {
|
|
292
|
+
inLineComment = true;
|
|
293
|
+
i++;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (char === "/" && next === "*") {
|
|
298
|
+
inBlockComment = true;
|
|
299
|
+
i++;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!/\s/.test(char)) {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Find the last closing brace of an object within an array range
|
|
313
|
+
*/
|
|
314
|
+
function findLastObjectEndInArray(content: string, startIndex: number, endIndex: number): number {
|
|
315
|
+
let lastBraceIndex = -1;
|
|
316
|
+
let inString = false;
|
|
317
|
+
let stringChar = "";
|
|
318
|
+
let escaped = false;
|
|
319
|
+
let inLineComment = false;
|
|
320
|
+
let inBlockComment = false;
|
|
321
|
+
|
|
322
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
323
|
+
const char = content[i] ?? "";
|
|
324
|
+
const next = content[i + 1] ?? "";
|
|
325
|
+
|
|
326
|
+
if (inLineComment) {
|
|
327
|
+
if (char === "\n") {
|
|
328
|
+
inLineComment = false;
|
|
329
|
+
}
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (inBlockComment) {
|
|
334
|
+
if (char === "*" && next === "/") {
|
|
335
|
+
inBlockComment = false;
|
|
336
|
+
i++;
|
|
337
|
+
}
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (inString) {
|
|
342
|
+
if (escaped) {
|
|
343
|
+
escaped = false;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (char === "\\") {
|
|
347
|
+
escaped = true;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (char === stringChar) {
|
|
351
|
+
inString = false;
|
|
352
|
+
stringChar = "";
|
|
353
|
+
}
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (char === "/" && next === "/") {
|
|
358
|
+
inLineComment = true;
|
|
359
|
+
i++;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (char === "/" && next === "*") {
|
|
364
|
+
inBlockComment = true;
|
|
365
|
+
i++;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (char === '"' || char === "'") {
|
|
370
|
+
inString = true;
|
|
371
|
+
stringChar = char;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (char === "}") {
|
|
376
|
+
lastBraceIndex = i;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return lastBraceIndex;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Determine if we need to add a comma before new content.
|
|
385
|
+
* Looks at the last non-whitespace, non-comment character.
|
|
386
|
+
*/
|
|
387
|
+
function shouldAddCommaBefore(content: string): boolean {
|
|
388
|
+
// Strip trailing comments and whitespace to find last meaningful char
|
|
389
|
+
let i = content.length - 1;
|
|
390
|
+
let inLineComment = false;
|
|
391
|
+
|
|
392
|
+
// First pass: find where any trailing line comment starts
|
|
393
|
+
for (let j = content.length - 1; j >= 0; j--) {
|
|
394
|
+
if (content[j] === "\n") {
|
|
395
|
+
// Check if there's a // comment on this line
|
|
396
|
+
const lineStart = content.lastIndexOf("\n", j - 1) + 1;
|
|
397
|
+
const line = content.slice(lineStart, j);
|
|
398
|
+
const commentIndex = findLineCommentStart(line);
|
|
399
|
+
if (commentIndex !== -1) {
|
|
400
|
+
i = lineStart + commentIndex - 1;
|
|
401
|
+
}
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Skip whitespace
|
|
407
|
+
while (i >= 0 && /\s/.test(content[i] ?? "")) {
|
|
408
|
+
i--;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (i < 0) return false;
|
|
412
|
+
|
|
413
|
+
const lastChar = content[i];
|
|
414
|
+
// Need comma if last char is }, ], ", number, or identifier char
|
|
415
|
+
// Don't need comma if last char is { or [ or ,
|
|
416
|
+
return lastChar !== "{" && lastChar !== "[" && lastChar !== ",";
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Find the start of a line comment (//) in a string, respecting strings
|
|
421
|
+
*/
|
|
422
|
+
function findLineCommentStart(line: string): number {
|
|
423
|
+
let inString = false;
|
|
424
|
+
let stringChar = "";
|
|
425
|
+
let escaped = false;
|
|
426
|
+
|
|
427
|
+
for (let i = 0; i < line.length - 1; i++) {
|
|
428
|
+
const char = line[i] ?? "";
|
|
429
|
+
const next = line[i + 1] ?? "";
|
|
430
|
+
|
|
431
|
+
if (inString) {
|
|
432
|
+
if (escaped) {
|
|
433
|
+
escaped = false;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (char === "\\") {
|
|
437
|
+
escaped = true;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (char === stringChar) {
|
|
441
|
+
inString = false;
|
|
442
|
+
stringChar = "";
|
|
443
|
+
}
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (char === '"' || char === "'") {
|
|
448
|
+
inString = true;
|
|
449
|
+
stringChar = char;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (char === "/" && next === "/") {
|
|
454
|
+
return i;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return -1;
|
|
459
|
+
}
|
package/src/mcp/tools/index.ts
CHANGED
|
@@ -5,6 +5,12 @@ import { JackError, JackErrorCode } from "../../lib/errors.ts";
|
|
|
5
5
|
import { createProject, deployProject, getProjectStatus } from "../../lib/project-operations.ts";
|
|
6
6
|
import { listAllProjects } from "../../lib/project-resolver.ts";
|
|
7
7
|
import { createDatabase } from "../../lib/services/db-create.ts";
|
|
8
|
+
import {
|
|
9
|
+
DestructiveOperationError,
|
|
10
|
+
WriteNotAllowedError,
|
|
11
|
+
executeSql,
|
|
12
|
+
wrapResultsForMcp,
|
|
13
|
+
} from "../../lib/services/db-execute.ts";
|
|
8
14
|
import { listDatabases } from "../../lib/services/db-list.ts";
|
|
9
15
|
import { Events, track, withTelemetry } from "../../lib/telemetry.ts";
|
|
10
16
|
import type { DebugLogger, McpServerOptions } from "../types.ts";
|
|
@@ -53,6 +59,25 @@ const ListDatabasesSchema = z.object({
|
|
|
53
59
|
.describe("Path to project directory (defaults to current directory)"),
|
|
54
60
|
});
|
|
55
61
|
|
|
62
|
+
const ExecuteSqlSchema = z.object({
|
|
63
|
+
sql: z.string().describe("SQL query to execute"),
|
|
64
|
+
project_path: z
|
|
65
|
+
.string()
|
|
66
|
+
.optional()
|
|
67
|
+
.describe("Path to project directory (defaults to current directory)"),
|
|
68
|
+
allow_write: z
|
|
69
|
+
.boolean()
|
|
70
|
+
.optional()
|
|
71
|
+
.default(false)
|
|
72
|
+
.describe(
|
|
73
|
+
"Allow write operations (INSERT, UPDATE, DELETE). Required for any data modification. Destructive operations (DROP, TRUNCATE) are blocked and must be run via CLI.",
|
|
74
|
+
),
|
|
75
|
+
database_name: z
|
|
76
|
+
.string()
|
|
77
|
+
.optional()
|
|
78
|
+
.describe("Database name (auto-detect from wrangler.jsonc if not provided)"),
|
|
79
|
+
});
|
|
80
|
+
|
|
56
81
|
export function registerTools(server: McpServer, _options: McpServerOptions, debug: DebugLogger) {
|
|
57
82
|
// Register tool list handler
|
|
58
83
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -155,6 +180,38 @@ export function registerTools(server: McpServer, _options: McpServerOptions, deb
|
|
|
155
180
|
},
|
|
156
181
|
},
|
|
157
182
|
},
|
|
183
|
+
{
|
|
184
|
+
name: "execute_sql",
|
|
185
|
+
description:
|
|
186
|
+
"Execute SQL against the project's D1 database. Read-only by default for safety. " +
|
|
187
|
+
"Set allow_write=true for INSERT, UPDATE, DELETE operations. " +
|
|
188
|
+
"Destructive operations (DROP, TRUNCATE, ALTER) are blocked and must be run via CLI with confirmation. " +
|
|
189
|
+
"Results are wrapped with anti-injection headers to prevent prompt injection from database content.",
|
|
190
|
+
inputSchema: {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: {
|
|
193
|
+
sql: {
|
|
194
|
+
type: "string",
|
|
195
|
+
description: "SQL query to execute",
|
|
196
|
+
},
|
|
197
|
+
project_path: {
|
|
198
|
+
type: "string",
|
|
199
|
+
description: "Path to project directory (defaults to current directory)",
|
|
200
|
+
},
|
|
201
|
+
allow_write: {
|
|
202
|
+
type: "boolean",
|
|
203
|
+
default: false,
|
|
204
|
+
description:
|
|
205
|
+
"Allow write operations (INSERT, UPDATE, DELETE). Required for any data modification.",
|
|
206
|
+
},
|
|
207
|
+
database_name: {
|
|
208
|
+
type: "string",
|
|
209
|
+
description: "Database name (auto-detect from wrangler.jsonc if not provided)",
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
required: ["sql"],
|
|
213
|
+
},
|
|
214
|
+
},
|
|
158
215
|
],
|
|
159
216
|
};
|
|
160
217
|
});
|
|
@@ -389,6 +446,110 @@ export function registerTools(server: McpServer, _options: McpServerOptions, deb
|
|
|
389
446
|
};
|
|
390
447
|
}
|
|
391
448
|
|
|
449
|
+
case "execute_sql": {
|
|
450
|
+
const args = ExecuteSqlSchema.parse(request.params.arguments ?? {});
|
|
451
|
+
const projectPath = args.project_path ?? process.cwd();
|
|
452
|
+
|
|
453
|
+
const wrappedExecuteSql = withTelemetry(
|
|
454
|
+
"execute_sql",
|
|
455
|
+
async (projectDir: string, sql: string, allowWrite: boolean, databaseName?: string) => {
|
|
456
|
+
try {
|
|
457
|
+
const result = await executeSql({
|
|
458
|
+
projectDir,
|
|
459
|
+
sql,
|
|
460
|
+
databaseName,
|
|
461
|
+
allowWrite,
|
|
462
|
+
interactive: false, // MCP is non-interactive
|
|
463
|
+
wrapResults: true,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// Track business event
|
|
467
|
+
track(Events.SQL_EXECUTED, {
|
|
468
|
+
risk_level: result.risk,
|
|
469
|
+
statement_count: result.statements.length,
|
|
470
|
+
platform: "mcp",
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// Wrap results with anti-injection header for MCP
|
|
474
|
+
const wrappedContent = wrapResultsForMcp(result.results ?? [], sql, result.meta);
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
success: result.success,
|
|
478
|
+
risk_level: result.risk,
|
|
479
|
+
results_wrapped: wrappedContent,
|
|
480
|
+
meta: result.meta,
|
|
481
|
+
warning: result.warning,
|
|
482
|
+
};
|
|
483
|
+
} catch (err) {
|
|
484
|
+
if (err instanceof WriteNotAllowedError) {
|
|
485
|
+
return {
|
|
486
|
+
success: false,
|
|
487
|
+
error: err.message,
|
|
488
|
+
suggestion: "Set allow_write=true to allow data modification",
|
|
489
|
+
risk_level: err.risk,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
if (err instanceof DestructiveOperationError) {
|
|
493
|
+
return {
|
|
494
|
+
success: false,
|
|
495
|
+
error: err.message,
|
|
496
|
+
suggestion:
|
|
497
|
+
"Destructive operations (DROP, TRUNCATE, ALTER, DELETE without WHERE) " +
|
|
498
|
+
"must be run via CLI with explicit confirmation: " +
|
|
499
|
+
`jack services db execute "${sql.slice(0, 50)}..." --write`,
|
|
500
|
+
risk_level: "destructive",
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
throw err;
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
{ platform: "mcp" },
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
const result = await wrappedExecuteSql(
|
|
510
|
+
projectPath,
|
|
511
|
+
args.sql,
|
|
512
|
+
args.allow_write ?? false,
|
|
513
|
+
args.database_name,
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
// Check if the result indicates an error (e.g., WriteNotAllowedError, DestructiveOperationError)
|
|
517
|
+
// These are returned as objects with success: false instead of thrown
|
|
518
|
+
const resultObj = result as Record<string, unknown>;
|
|
519
|
+
if (resultObj?.success === false) {
|
|
520
|
+
return {
|
|
521
|
+
content: [
|
|
522
|
+
{
|
|
523
|
+
type: "text",
|
|
524
|
+
text: JSON.stringify(
|
|
525
|
+
{
|
|
526
|
+
success: false,
|
|
527
|
+
error: resultObj.error,
|
|
528
|
+
suggestion: resultObj.suggestion,
|
|
529
|
+
risk_level: resultObj.risk_level,
|
|
530
|
+
meta: {
|
|
531
|
+
duration_ms: Date.now() - startTime,
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
null,
|
|
535
|
+
2,
|
|
536
|
+
),
|
|
537
|
+
},
|
|
538
|
+
],
|
|
539
|
+
isError: true,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
content: [
|
|
545
|
+
{
|
|
546
|
+
type: "text",
|
|
547
|
+
text: JSON.stringify(formatSuccessResponse(result, startTime), null, 2),
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
392
553
|
default:
|
|
393
554
|
throw new Error(`Unknown tool: ${toolName}`);
|
|
394
555
|
}
|
package/src/templates/index.ts
CHANGED
|
@@ -73,6 +73,7 @@ async function loadTemplate(name: string): Promise<Template> {
|
|
|
73
73
|
description: string;
|
|
74
74
|
secrets: string[];
|
|
75
75
|
optionalSecrets?: Template["optionalSecrets"];
|
|
76
|
+
envVars?: Template["envVars"];
|
|
76
77
|
capabilities?: Template["capabilities"];
|
|
77
78
|
requires?: Template["requires"];
|
|
78
79
|
hooks?: Template["hooks"];
|
|
@@ -90,6 +91,7 @@ async function loadTemplate(name: string): Promise<Template> {
|
|
|
90
91
|
description: metadata.description,
|
|
91
92
|
secrets: metadata.secrets,
|
|
92
93
|
optionalSecrets: metadata.optionalSecrets,
|
|
94
|
+
envVars: metadata.envVars,
|
|
93
95
|
capabilities: metadata.capabilities,
|
|
94
96
|
requires: metadata.requires,
|
|
95
97
|
hooks: metadata.hooks,
|
|
@@ -165,6 +167,7 @@ async function fetchPublishedTemplate(username: string, slug: string): Promise<T
|
|
|
165
167
|
description: (metadata.description as string) || `Fork of ${username}/${slug}`,
|
|
166
168
|
secrets: (metadata.secrets as string[]) || [],
|
|
167
169
|
optionalSecrets: metadata.optionalSecrets as Template["optionalSecrets"],
|
|
170
|
+
envVars: metadata.envVars as Template["envVars"],
|
|
168
171
|
capabilities: metadata.capabilities as Template["capabilities"],
|
|
169
172
|
requires: metadata.requires as Template["requires"],
|
|
170
173
|
hooks: metadata.hooks as Template["hooks"],
|
|
@@ -200,6 +203,7 @@ async function fetchUserTemplate(slug: string): Promise<Template | null> {
|
|
|
200
203
|
description: (metadata.description as string) || `Your project: ${slug}`,
|
|
201
204
|
secrets: (metadata.secrets as string[]) || [],
|
|
202
205
|
optionalSecrets: metadata.optionalSecrets as Template["optionalSecrets"],
|
|
206
|
+
envVars: metadata.envVars as Template["envVars"],
|
|
203
207
|
capabilities: metadata.capabilities as Template["capabilities"],
|
|
204
208
|
requires: metadata.requires as Template["requires"],
|
|
205
209
|
hooks: metadata.hooks as Template["hooks"],
|
package/src/templates/types.ts
CHANGED
|
@@ -16,6 +16,8 @@ export type HookAction =
|
|
|
16
16
|
path: string;
|
|
17
17
|
set: Record<string, string | { from: "input" }>;
|
|
18
18
|
};
|
|
19
|
+
deployAfter?: boolean; // Redeploy after successful input (only if user provided input)
|
|
20
|
+
deployMessage?: string; // Message to show during deploy (default: "Deploying...")
|
|
19
21
|
}
|
|
20
22
|
| {
|
|
21
23
|
action: "writeJson";
|
|
@@ -53,6 +55,15 @@ export interface OptionalSecret {
|
|
|
53
55
|
setupUrl?: string;
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
export interface EnvVar {
|
|
59
|
+
name: string;
|
|
60
|
+
description: string;
|
|
61
|
+
required?: boolean;
|
|
62
|
+
defaultValue?: string;
|
|
63
|
+
example?: string;
|
|
64
|
+
setupUrl?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
56
67
|
export interface IntentMetadata {
|
|
57
68
|
keywords: string[];
|
|
58
69
|
examples?: string[]; // For future telemetry/docs
|
|
@@ -62,6 +73,7 @@ export interface Template {
|
|
|
62
73
|
files: Record<string, string>; // path -> content
|
|
63
74
|
secrets?: string[]; // required secret keys (e.g., ["NEYNAR_API_KEY"])
|
|
64
75
|
optionalSecrets?: OptionalSecret[]; // optional secret configurations
|
|
76
|
+
envVars?: EnvVar[]; // environment variables (non-secret config)
|
|
65
77
|
capabilities?: Capability[]; // infrastructure requirements (deprecated, use requires)
|
|
66
78
|
requires?: ServiceTypeKey[]; // service requirements (DB, KV, CRON, QUEUE, STORAGE)
|
|
67
79
|
description?: string; // for help text
|