@ductape/mcp 0.2.32 → 0.2.34
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/dist/index.js +370 -13
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -311,6 +311,23 @@ When the target application is a NestJS service or controller, always use @ducta
|
|
|
311
311
|
instead of instantiating @ductape/sdk directly. It provides NestJS DI integration,
|
|
312
312
|
global interceptors, decorators, and type-safe resource handles.
|
|
313
313
|
|
|
314
|
+
PRODUCT RUNTIME SNAPSHOT — REQUIRED FOR SERVER INITIALIZATION:
|
|
315
|
+
Always provide both product and env at module initialization. Nest awaits the SDK's initial
|
|
316
|
+
product runtime snapshot during onModuleInit, before the application accepts traffic. The
|
|
317
|
+
snapshot preloads connected App versions/actions plus database actions/schema metadata, graphs,
|
|
318
|
+
vectors, storage, brokers/topics, sessions, notifications, resilience assets, agents, functions,
|
|
319
|
+
and caches into the execution bootstrap cache. External provider/database connections remain
|
|
320
|
+
lazy and pooled.
|
|
321
|
+
|
|
322
|
+
Runtime synchronization is enabled by default when product + env are present. Do not set
|
|
323
|
+
runtimeSync:false merely to simplify generated code. Configure it only when the user has an
|
|
324
|
+
explicit operational reason:
|
|
325
|
+
runtimeSync: { intervalMs: 30_000, jitter: 0.2, maxBackoffMs: 300_000 }
|
|
326
|
+
|
|
327
|
+
Polling uses runtimeRevision + HTTP ETag/304. databaseRevision is a refresh hint included in the
|
|
328
|
+
umbrella runtimeRevision, so schema and Database Action changes are detected. Failed refreshes
|
|
329
|
+
retain the last-known-good snapshot. Nest stops polling through onModuleDestroy automatically.
|
|
330
|
+
|
|
314
331
|
SETUP — register once in AppModule:
|
|
315
332
|
|
|
316
333
|
╔══════════════════════════════════════════════════════════════════════════╗
|
|
@@ -338,6 +355,7 @@ SETUP — register once in AppModule:
|
|
|
338
355
|
product: 'my-product',
|
|
339
356
|
env: process.env.NODE_ENV === 'production' ? 'prd' : 'snd',
|
|
340
357
|
redisUrl: process.env.DUCTAPE_REDIS_URL, // required — no dispatch() works without this
|
|
358
|
+
runtimeSync: { intervalMs: 30_000, jitter: 0.2 },
|
|
341
359
|
}),
|
|
342
360
|
],
|
|
343
361
|
})
|
|
@@ -352,6 +370,7 @@ SETUP — register once in AppModule:
|
|
|
352
370
|
product: cfg.get('DUCTAPE_PRODUCT'),
|
|
353
371
|
env: cfg.get('DUCTAPE_ENV'),
|
|
354
372
|
redisUrl: cfg.get('DUCTAPE_REDIS_URL'), // required — no dispatch() works without this
|
|
373
|
+
runtimeSync: { intervalMs: 30_000, jitter: 0.2 },
|
|
355
374
|
}),
|
|
356
375
|
})
|
|
357
376
|
|
|
@@ -1128,7 +1147,8 @@ const payloadGenerateInputSchema = z.object({
|
|
|
1128
1147
|
targets: z.record(z.any()).optional().describe('Identifies the specific operation to generate a payload for. ' +
|
|
1129
1148
|
'For actions: { app: "app_tag", action: "action_tag" }. ' +
|
|
1130
1149
|
'For features: { feature: "feature_tag" }. ' +
|
|
1131
|
-
'For databases: { database: "db_tag", table
|
|
1150
|
+
'For databases: { database: "db_tag", table?: "table_or_collection_name", action?: "action_tag" }. ' +
|
|
1151
|
+
' When generating code for a saved action, action is required and its static outputContract must be inspected. ' +
|
|
1132
1152
|
' Providing table is strongly recommended — the generator scans all actions configured for that table, ' +
|
|
1133
1153
|
' aggregates field definitions (name, type, required, sample value), and returns them in ' +
|
|
1134
1154
|
' meta.schema_context.database.fields. The where/data placeholders in the payload are also ' +
|
|
@@ -1161,11 +1181,16 @@ const snippetGenerateInputSchema = payloadGenerateInputSchema.extend({
|
|
|
1161
1181
|
language: z.enum(['typescript', 'python']).default('typescript'),
|
|
1162
1182
|
});
|
|
1163
1183
|
const schemaInputSchema = z.object({
|
|
1184
|
+
publishable_key: z.string().optional().describe('Optional publishable key for live database schema lookup. Omit when DUCTAPE_PUBLISHABLE_KEY is configured.'),
|
|
1164
1185
|
module: z.enum(['app', 'product']).optional().describe('Optional. Scope the result to one module. With no method, returns only the compact list of available methods.'),
|
|
1165
1186
|
method: z.string().optional().describe('Optional method key such as "databases.create" or "notifications.update". Use with module to return only that method schema.'),
|
|
1166
1187
|
product_tag: z.string().optional().describe('For live app-action discovery, the product the app is linked to. Must be supplied with app_tag and action_tag.'),
|
|
1167
1188
|
app_tag: z.string().optional().describe('For live app-action discovery, the linked app tag. Must be supplied with product_tag and action_tag.'),
|
|
1168
|
-
action_tag: z.string().optional().describe('For live app-action discovery, the exact action tag whose complete contract should be returned.'),
|
|
1189
|
+
action_tag: z.string().optional().describe('For live app-action or database-action discovery, the exact action tag whose complete contract should be returned.'),
|
|
1190
|
+
database_tag: z.string().optional().describe('For live database discovery, the database component tag. Requires product_tag and env_slug.'),
|
|
1191
|
+
env_slug: z.string().optional().describe('For live database discovery, the environment whose synchronized table schema and action contract should be used.'),
|
|
1192
|
+
table: z.string().optional().describe('For live database discovery, the exact table/collection. Required for direct query row contracts; inferred from a saved action when action_tag is supplied.'),
|
|
1193
|
+
database_operation: z.enum(['query', 'insert', 'update', 'delete', 'upsert', 'aggregate', 'raw']).optional().default('query').describe('Direct database primitive whose envelope should be combined with the table schema. Ignored when action_tag identifies a saved action.'),
|
|
1169
1194
|
});
|
|
1170
1195
|
const marketplaceDiscoverInputSchema = z.object({
|
|
1171
1196
|
query: z.string().min(1).describe('Capability or provider search, for example "payments" or "paystack".'),
|
|
@@ -1306,18 +1331,24 @@ function buildTypeScriptSnippet(payload, operationFamily, method) {
|
|
|
1306
1331
|
const invocationArgs = buildSdkInvocationArgs(payload);
|
|
1307
1332
|
return `import Ductape from "@ductape/sdk";
|
|
1308
1333
|
|
|
1334
|
+
const payload = ${toPrettyJson(payload)};
|
|
1309
1335
|
const ductape = new Ductape({
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1336
|
+
accessKey: process.env.DUCTAPE_ACCESS_KEY!,
|
|
1337
|
+
product: String(payload.product),
|
|
1338
|
+
env: String(payload.env),
|
|
1313
1339
|
redis_url: process.env.DUCTAPE_REDIS_URL,
|
|
1340
|
+
runtime_sync: { interval_ms: 30_000, jitter: 0.2 },
|
|
1314
1341
|
});
|
|
1315
1342
|
|
|
1316
1343
|
async function run() {
|
|
1317
|
-
|
|
1344
|
+
await ductape.ready();
|
|
1318
1345
|
const args = ${toPrettyJson(invocationArgs)};
|
|
1319
|
-
|
|
1320
|
-
|
|
1346
|
+
try {
|
|
1347
|
+
const result = await ductape.${callPath}(args);
|
|
1348
|
+
return { payload, result };
|
|
1349
|
+
} finally {
|
|
1350
|
+
await ductape.close();
|
|
1351
|
+
}
|
|
1321
1352
|
}
|
|
1322
1353
|
|
|
1323
1354
|
run().catch(console.error);
|
|
@@ -1351,6 +1382,44 @@ function buildSnippet(language, payload, operationFamily, method) {
|
|
|
1351
1382
|
? buildPythonSnippet(payload, operationFamily, method)
|
|
1352
1383
|
: buildTypeScriptSnippet(payload, operationFamily, method);
|
|
1353
1384
|
}
|
|
1385
|
+
function buildDatabaseActionFeatureExample(targets, contract) {
|
|
1386
|
+
const database = targets.database ?? targets.database_tag ?? '<database-tag>';
|
|
1387
|
+
const action = targets.action ?? targets.action_tag ?? '<action-tag>';
|
|
1388
|
+
if (!contract?.normalizedOutput) {
|
|
1389
|
+
return `// Output contract unknown: do not infer a row, array, or envelope.\n` +
|
|
1390
|
+
`// Sync the table schema and fetch the Database Action contract before generating this Feature.`;
|
|
1391
|
+
}
|
|
1392
|
+
const operation = String(contract.operation ?? '').toUpperCase();
|
|
1393
|
+
if (operation === 'QUERY' || operation === 'READ') {
|
|
1394
|
+
return `type DatabaseActionResult = { data: Record<string, unknown>[]; count: number; fields?: string[] };\n\n` +
|
|
1395
|
+
`const queryResult = await ctx.step("${action}", () =>\n` +
|
|
1396
|
+
` ctx.database.execute<DatabaseActionResult>({\n` +
|
|
1397
|
+
` database: ${JSON.stringify(database)},\n` +
|
|
1398
|
+
` action: ${JSON.stringify(action)},\n` +
|
|
1399
|
+
` input: { /* values from ctx.input */ },\n` +
|
|
1400
|
+
` }),\n` +
|
|
1401
|
+
`);\n\n` +
|
|
1402
|
+
`await ctx.branch(ctx.when.eq(ctx.transform.length(queryResult.data), 0), {\n` +
|
|
1403
|
+
` then: async () => { /* handle successful empty query */ },\n` +
|
|
1404
|
+
` else: async () => { const row = queryResult.data[0]; /* consume validated row */ },\n` +
|
|
1405
|
+
`});`;
|
|
1406
|
+
}
|
|
1407
|
+
if ((operation === 'INSERT' || operation === 'CREATE') && contract.createdRecordPath === 'data[0]') {
|
|
1408
|
+
return `const insertResult = await ctx.step("${action}", () =>\n` +
|
|
1409
|
+
` ctx.database.execute<{ data: Record<string, unknown>[]; count: number; insertedIds: unknown[] }>({\n` +
|
|
1410
|
+
` database: ${JSON.stringify(database)}, action: ${JSON.stringify(action)}, input: { /* values */ },\n` +
|
|
1411
|
+
` }),\n` +
|
|
1412
|
+
`);\n` +
|
|
1413
|
+
`const createdRow = insertResult.data[0]; // No redundant follow-up query: contract returns rows.`;
|
|
1414
|
+
}
|
|
1415
|
+
return `// ${operation} returns ${contract.returnedRows ?? 'unknown'} rows.\n` +
|
|
1416
|
+
`const result = await ctx.step("${action}", () =>\n` +
|
|
1417
|
+
` ctx.database.execute({ database: ${JSON.stringify(database)}, action: ${JSON.stringify(action)}, input: { /* values */ } }),\n` +
|
|
1418
|
+
`);\n` +
|
|
1419
|
+
(contract.followUpQueryRequired
|
|
1420
|
+
? `// This contract returns mutation metadata only; add an explicit saved QUERY action before consuming a row.`
|
|
1421
|
+
: `// Consume only fields declared by normalizedOutput.`);
|
|
1422
|
+
}
|
|
1354
1423
|
// ─── CLI helpers ─────────────────────────────────────────────────────────────
|
|
1355
1424
|
// Per-process cache: avoids re-running whoami / workspaces use on every call.
|
|
1356
1425
|
let authState = 'unknown';
|
|
@@ -1554,7 +1623,7 @@ function shellArgument(value) {
|
|
|
1554
1623
|
}
|
|
1555
1624
|
const docsInputSchema = z.object({
|
|
1556
1625
|
topic: z.string().describe('Feature topic to look up. Supported: ' +
|
|
1557
|
-
'transactions, presave, triggers, aggregations, migrations, indexes, performance, actions, ' +
|
|
1626
|
+
'transactions, presave, triggers, aggregations, migrations, indexes, performance, runtime-sync, actions, ' +
|
|
1558
1627
|
'graphs, storage, cloud, vector, warehouse, secrets, apps, products, sessions, caches, ' +
|
|
1559
1628
|
'notifications, resilience, features, portable-functions, events, logs, migration, cli-authentication, frontend, frontend-analytics, client, react, vue'),
|
|
1560
1629
|
});
|
|
@@ -2465,6 +2534,39 @@ DUCTAPE DATABASE PERFORMANCE GUIDANCE
|
|
|
2465
2534
|
5. Caching
|
|
2466
2535
|
- For read-heavy, rarely-changing data use caches.get before databases.query.
|
|
2467
2536
|
- Invalidate cache keys in an afterWrite trigger (see ductape_docs({ topic: "triggers" })).
|
|
2537
|
+
`.trim(),
|
|
2538
|
+
'runtime-sync': `
|
|
2539
|
+
DUCTAPE PRODUCT RUNTIME SNAPSHOTS
|
|
2540
|
+
|
|
2541
|
+
For every long-lived TypeScript/Node backend, initialize @ductape/sdk with accessKey, product,
|
|
2542
|
+
and env, then await ready() before accepting work:
|
|
2543
|
+
|
|
2544
|
+
const ductape = new Ductape({
|
|
2545
|
+
accessKey: process.env.DUCTAPE_ACCESS_KEY!,
|
|
2546
|
+
product: "payments",
|
|
2547
|
+
env: "prd",
|
|
2548
|
+
runtime_sync: { interval_ms: 30_000, jitter: 0.2, max_backoff_ms: 300_000 },
|
|
2549
|
+
});
|
|
2550
|
+
await ductape.ready();
|
|
2551
|
+
|
|
2552
|
+
This fetches one environment-scoped product snapshot and primes the existing bootstrap cache with
|
|
2553
|
+
connected App versions/actions, databases/actions/table schemas, graphs, vectors, storage,
|
|
2554
|
+
brokers/topics, sessions, notifications, resilience assets, agents, functions, and caches.
|
|
2555
|
+
Connections remain lazy and pooled; snapshot bootstrap does not call external providers.
|
|
2556
|
+
|
|
2557
|
+
Runtime synchronization is automatic when product + env are supplied:
|
|
2558
|
+
- Conditional polling sends If-None-Match with runtimeRevision; unchanged state returns HTTP 304.
|
|
2559
|
+
- runtimeRevision is authoritative. databaseRevision, connectionRevision, secretRevision, and
|
|
2560
|
+
assetRevision are narrower change hints. Any databaseRevision change also changes runtimeRevision.
|
|
2561
|
+
- A changed snapshot is loaded off-path and atomically replaces cache entries. Deleted assets are removed.
|
|
2562
|
+
- Refresh failure retains the last-known-good snapshot and retries with jittered exponential backoff.
|
|
2563
|
+
- Use runtimeSnapshotStatus() for revision/error telemetry and refreshRuntime() for an explicit pull.
|
|
2564
|
+
- Call close() during process shutdown. Do not create one Ductape instance per request.
|
|
2565
|
+
|
|
2566
|
+
For NestJS, use DuctapeModule.forIntegration({ accessKey, product, env, runtimeSync }) instead of
|
|
2567
|
+
new Ductape(). @ductape/nestjs awaits ready() in onModuleInit and closes the poller in
|
|
2568
|
+
onModuleDestroy. Never omit product/env when the service has stable defaults: doing so disables
|
|
2569
|
+
product snapshot bootstrap and returns execution to lazy per-asset bootstrap misses.
|
|
2468
2570
|
`.trim(),
|
|
2469
2571
|
actions: `
|
|
2470
2572
|
DUCTAPE DATABASE ACTIONS
|
|
@@ -2475,12 +2577,124 @@ ctx.database.execute inside a Feature step invokes a saved action through its \`
|
|
|
2475
2577
|
Direct ctx.database.query/insert/update/delete calls retain their normal {table, where, data, ...}
|
|
2476
2578
|
shape and do not resolve a saved action.
|
|
2477
2579
|
|
|
2580
|
+
HARDENED DIRECT DATABASE PRIMITIVE CONTRACTS:
|
|
2581
|
+
Direct calls also return typed adapter envelopes; they do not return a raw row, raw array,
|
|
2582
|
+
boolean, or arbitrary T. Derive row fields from the selected table schema and use these exact
|
|
2583
|
+
normalized contracts:
|
|
2584
|
+
ctx.database.query<Row>(...) → { data: Row[], count: number, fields?: string[] }
|
|
2585
|
+
ctx.database.insert<Row>(...) → { data: Row[], count: number, insertedIds: unknown[] }
|
|
2586
|
+
ctx.database.update<Row>(...) → { data: Row[], count: number }
|
|
2587
|
+
ctx.database.delete(...) → { count: number, data?: Row[] }
|
|
2588
|
+
ctx.database.upsert<Row>(...) → { data: Row[], count: number, operation: "inserted"|"updated" }
|
|
2589
|
+
ctx.database.raw<Row>(...) → { data: Row[], count: number, fields?: string[], rowsAffected?: number }
|
|
2590
|
+
ctx.database.aggregate(...) → an alias-keyed aggregate object declared by operations; it is
|
|
2591
|
+
NOT a row envelope unless the selected adapter contract says so.
|
|
2592
|
+
|
|
2593
|
+
Never generate queryResult[0], queryResult.length, a boolean check on deleteResult, or direct
|
|
2594
|
+
field access such as insertResult.id. Query rows live at queryResult.data; delete success/count
|
|
2595
|
+
is deleteResult.count; inserted/updated rows exist in mutationResult.data only when returning is
|
|
2596
|
+
true. INSERT always includes insertedIds, but an inserted ID is not a complete row.
|
|
2597
|
+
|
|
2598
|
+
Direct primitive contracts are derived from the explicit method, adapter/version, table schema,
|
|
2599
|
+
and call options such as returning, returningColumns, select, limit, and aggregation aliases.
|
|
2600
|
+
Never infer row fields from domain expectations. If table schema is missing, call static schema
|
|
2601
|
+
discovery/sync and mark row fields unknown; do not execute the operation merely to learn shape.
|
|
2602
|
+
|
|
2603
|
+
DIRECT FEATURE-SAFE EXAMPLES:
|
|
2604
|
+
const queryResult = await ctx.step("find-customer", () =>
|
|
2605
|
+
ctx.database.query<Customer>({
|
|
2606
|
+
database: "payment-processing", table: "customers",
|
|
2607
|
+
where: { email: ctx.input.customerEmail }, limit: 1,
|
|
2608
|
+
}),
|
|
2609
|
+
);
|
|
2610
|
+
await ctx.branch(ctx.when.eq(ctx.transform.length(queryResult.data), 0), {
|
|
2611
|
+
then: async () => { /* create */ },
|
|
2612
|
+
else: async () => { const customer = queryResult.data[0]; /* consume */ },
|
|
2613
|
+
});
|
|
2614
|
+
|
|
2615
|
+
const insertResult = await ctx.step("create-customer", () =>
|
|
2616
|
+
ctx.database.insert<Customer>({
|
|
2617
|
+
database: "payment-processing", table: "customers", data: { /* fields */ }, returning: true,
|
|
2618
|
+
}),
|
|
2619
|
+
);
|
|
2620
|
+
// returning:true declares created rows at insertResult.data; do not re-query.
|
|
2621
|
+
const createdCustomer = insertResult.data[0];
|
|
2622
|
+
|
|
2623
|
+
Empty { data: [], count: 0 } is successful. Thrown/explicit failure is a failed step. A successful
|
|
2624
|
+
value that violates the primitive's declared envelope is a platform contract violation and must
|
|
2625
|
+
fail at the producing step boundary. Never let a missing required row field flow to an app action.
|
|
2626
|
+
|
|
2478
2627
|
REUSE BEFORE CREATE:
|
|
2479
2628
|
1. ductape_cli("db actions list --database product_tag:database_tag --json")
|
|
2480
2629
|
2. Inspect likely matches with ductape_cli("db actions get product_tag:database_tag:action_tag --json").
|
|
2481
2630
|
3. Reuse the matching tag with database.execute/ctx.database.execute when operation, table,
|
|
2482
2631
|
template, parameters, and result semantics match. Create only when no exact functional match exists.
|
|
2483
2632
|
|
|
2633
|
+
OUTPUT CONTRACTS ARE MANDATORY DISCOVERY METADATA:
|
|
2634
|
+
Every action returned by databases.action.fetch/fetchAll includes outputContract and
|
|
2635
|
+
outputContractsByEnvironment. Read the contract for the target environment before writing a
|
|
2636
|
+
Feature. It contains input, normalizedOutput, rowSchema, cardinality, returnedRows,
|
|
2637
|
+
returnsAffectedRows, createdRecordPath, followUpQueryRequired, adapter, and adapterOutput.
|
|
2638
|
+
|
|
2639
|
+
Never infer a Database Action's output shape from its operation name, generic SDK knowledge,
|
|
2640
|
+
or application-domain expectations. Derive it from the database adapter, action definition,
|
|
2641
|
+
table schema metadata, and action outputContract. If the contract confidence is "unknown" or
|
|
2642
|
+
normalizedOutput is null, state that the output is unknown and request platform metadata; do
|
|
2643
|
+
not invent a raw row, array, envelope, or field. Static discovery must never execute the action.
|
|
2644
|
+
|
|
2645
|
+
MANDATORY DUCTAPE_SCHEMA ROW LOOKUP:
|
|
2646
|
+
The normalized envelope does NOT reveal the fields inside Row. Before generating code that reads
|
|
2647
|
+
result.data[n].field (or evaluates a row field in a Feature branch), call one of these exact forms:
|
|
2648
|
+
Saved Database Action:
|
|
2649
|
+
ductape_schema({ product_tag: "<product_tag>", env_slug: "<env_slug>",
|
|
2650
|
+
database_tag: "<database_tag>", action_tag: "<action_tag>" })
|
|
2651
|
+
Direct database query/primitive:
|
|
2652
|
+
ductape_schema({ product_tag: "<product_tag>", env_slug: "<env_slug>",
|
|
2653
|
+
database_tag: "<database_tag>", table: "<table>", database_operation: "query" })
|
|
2654
|
+
Use output_contract only for the saved action's operation-specific envelope. Use row_schema for
|
|
2655
|
+
the contents of each result.data item for BOTH saved actions and direct calls. Projection/select
|
|
2656
|
+
may narrow those fields further. If row_schema is empty or warnings report missing synchronized
|
|
2657
|
+
metadata, run \`ductape db schema push --db <database_tag> --env <env_slug>\`, retry
|
|
2658
|
+
ductape_schema, and keep row fields unknown until it succeeds. Never execute the query/action to
|
|
2659
|
+
discover its shape, and never substitute a generic product asset schema for this live lookup.
|
|
2660
|
+
|
|
2661
|
+
NORMALIZED ENVELOPES DIFFER BY OPERATION:
|
|
2662
|
+
QUERY → { data: Row[], count: number, fields?: string[] }
|
|
2663
|
+
INSERT → { data: Row[], count: number, insertedIds: unknown[] }
|
|
2664
|
+
UPDATE → { data: Row[], count: number }
|
|
2665
|
+
DELETE → { count: number, data?: Row[] }
|
|
2666
|
+
RAW/EXECUTE → { data: Row[], count: number, fields?: string[], rowsAffected?: number }
|
|
2667
|
+
returning controls whether mutation data contains rows. Do not describe all operations as the
|
|
2668
|
+
same envelope. Adapter-native details belong in adapterOutput; Feature code uses normalizedOutput.
|
|
2669
|
+
|
|
2670
|
+
FEATURE-SAFE QUERY PATTERN:
|
|
2671
|
+
type FindCustomerResult = { data: Customer[]; count: number; fields?: string[] };
|
|
2672
|
+
const queryResult = await ctx.step("find-customer", () =>
|
|
2673
|
+
ctx.database.execute<FindCustomerResult>({
|
|
2674
|
+
database: "payment-processing",
|
|
2675
|
+
action: "find-customer-by-email",
|
|
2676
|
+
input: { email: ctx.input.customerEmail },
|
|
2677
|
+
}),
|
|
2678
|
+
);
|
|
2679
|
+
await ctx.branch(ctx.when.eq(ctx.transform.length(queryResult.data), 0), {
|
|
2680
|
+
then: async () => { /* create */ },
|
|
2681
|
+
else: async () => {
|
|
2682
|
+
const customer = queryResult.data[0];
|
|
2683
|
+
/* consume customer only inside this structurally valid branch */
|
|
2684
|
+
},
|
|
2685
|
+
});
|
|
2686
|
+
Never generate queryResult[0] or ctx.transform.length(queryResult) for an envelope contract.
|
|
2687
|
+
{ data: [], count: 0 } is a successful empty query, not a failed action.
|
|
2688
|
+
|
|
2689
|
+
MUTATION FLOW:
|
|
2690
|
+
Inspect returnedRows, createdRecordPath, and followUpQueryRequired. If INSERT returns rows, use
|
|
2691
|
+
insertResult.data[0] directly and do not add a redundant resolve query. If it returns metadata
|
|
2692
|
+
only, explain that fact and add an explicit follow-up query. Never allow an undefined required
|
|
2693
|
+
id/email to reach a downstream app/provider action: validate it or make that step structurally
|
|
2694
|
+
dependent on queryResult.data[0] or the declared createdRecordPath. A thrown/failed response is
|
|
2695
|
+
a failed step; a successful value that violates normalizedOutput is a platform contract violation
|
|
2696
|
+
and must fail at the producing step boundary.
|
|
2697
|
+
|
|
2484
2698
|
Create/update/delete are ADMINISTRATIVE (access key) — FORBIDDEN through ductape_execute
|
|
2485
2699
|
(publishable key). Use ductape_cli, same as every other admin resource:
|
|
2486
2700
|
ductape_cli("db actions create --action-file ductape/database/actions/<action-tag>.action.json")
|
|
@@ -3046,8 +3260,11 @@ Product structure (IProduct fields):
|
|
|
3046
3260
|
workflows[] (features), models[], agents[], jobs[]
|
|
3047
3261
|
|
|
3048
3262
|
Bootstrap (single API call returning product context + component config + private key):
|
|
3049
|
-
|
|
3050
|
-
|
|
3263
|
+
Initialize @ductape/sdk with product + env and await ductape.ready(). It fetches the full
|
|
3264
|
+
environment-scoped product runtime snapshot and primes BootstrapCache for connected App actions
|
|
3265
|
+
and product components. A service falls back to its targeted bootstrap endpoint only when the
|
|
3266
|
+
requested asset is absent. Polling uses ETag/304 and atomically refreshes changed state.
|
|
3267
|
+
See ductape_docs({ topic: "runtime-sync" }).
|
|
3051
3268
|
`.trim(),
|
|
3052
3269
|
sessions: `
|
|
3053
3270
|
DUCTAPE SESSIONS
|
|
@@ -3962,7 +4179,7 @@ STEP 7 — HANDLE conditionals, loops, and branching (when applicable)
|
|
|
3962
4179
|
|
|
3963
4180
|
Prefer explicit portable branching for step results:
|
|
3964
4181
|
const result = await ctx.step("find", () => ctx.database.execute(...));
|
|
3965
|
-
await ctx.branch(ctx.when.eq(result.
|
|
4182
|
+
await ctx.branch(ctx.when.eq(ctx.transform.length(result.data), 0), {
|
|
3966
4183
|
then: () => ctx.step("create", () => ctx.database.execute(...)),
|
|
3967
4184
|
else: () => ctx.step("reuse", () => ctx.database.execute(...)),
|
|
3968
4185
|
});
|
|
@@ -4381,8 +4598,12 @@ Import (register an EXISTING cloud resource):
|
|
|
4381
4598
|
// dispatch() requires redis_url in the Ductape initialization options — it throws at runtime without it.
|
|
4382
4599
|
const ductape = new Ductape({
|
|
4383
4600
|
accessKey: process.env.DUCTAPE_ACCESS_KEY,
|
|
4601
|
+
product: "my-product",
|
|
4602
|
+
env: "prd",
|
|
4384
4603
|
redis_url: process.env.DUCTAPE_REDIS_URL, // required for any dispatch(); omit only if never dispatching
|
|
4604
|
+
runtime_sync: { interval_ms: 30_000, jitter: 0.2 },
|
|
4385
4605
|
});
|
|
4606
|
+
await ductape.ready();
|
|
4386
4607
|
await ductape.events.produce({
|
|
4387
4608
|
product: "my-product",
|
|
4388
4609
|
env: "prd",
|
|
@@ -4405,6 +4626,7 @@ Import (register an EXISTING cloud resource):
|
|
|
4405
4626
|
product: 'my-product',
|
|
4406
4627
|
env: process.env.NODE_ENV === 'production' ? 'prd' : 'snd',
|
|
4407
4628
|
redisUrl: process.env.DUCTAPE_REDIS_URL, // required — dispatch() throws without this
|
|
4629
|
+
runtimeSync: { intervalMs: 30_000, jitter: 0.2 },
|
|
4408
4630
|
}),
|
|
4409
4631
|
});
|
|
4410
4632
|
// Environment: DUCTAPE_REDIS_URL=redis://localhost:6379 (local) or rediss://:<pw>@host:6380 (managed)
|
|
@@ -6065,6 +6287,38 @@ async function main() {
|
|
|
6065
6287
|
const generated = addSessionAwarenessMetadata(await generateExecutablePayload({ ...args, include_session: includeSession, publishable_key: key }), args);
|
|
6066
6288
|
const payload = generated?.payload ?? {};
|
|
6067
6289
|
const snippet = buildSnippet(args.language, payload, args.operation_family, args.method);
|
|
6290
|
+
let databaseActionContract = null;
|
|
6291
|
+
let featureExample;
|
|
6292
|
+
if (args.operation_family === 'database') {
|
|
6293
|
+
const targets = (args.targets ?? {});
|
|
6294
|
+
const action = targets.action ?? targets.action_tag;
|
|
6295
|
+
const database = targets.database ?? targets.database_tag;
|
|
6296
|
+
if (action && database) {
|
|
6297
|
+
const databaseTag = String(database).startsWith(`${args.product_tag}:`)
|
|
6298
|
+
? String(database)
|
|
6299
|
+
: `${args.product_tag}:${database}`;
|
|
6300
|
+
const fullActionTag = String(action).startsWith(`${databaseTag}:`)
|
|
6301
|
+
? String(action)
|
|
6302
|
+
: `${databaseTag}:${action}`;
|
|
6303
|
+
const response = await cliHandler({
|
|
6304
|
+
command: `db actions get ${shellArgument(fullActionTag)} --json`,
|
|
6305
|
+
});
|
|
6306
|
+
if (!response.isError) {
|
|
6307
|
+
const raw = response.content[0]?.type === 'text' ? response.content[0].text : '';
|
|
6308
|
+
try {
|
|
6309
|
+
const parsed = JSON.parse(raw);
|
|
6310
|
+
const definition = parsed?.data ?? parsed?.result ?? parsed;
|
|
6311
|
+
databaseActionContract = definition?.outputContractsByEnvironment?.[args.env_slug]
|
|
6312
|
+
?? definition?.outputContract
|
|
6313
|
+
?? null;
|
|
6314
|
+
}
|
|
6315
|
+
catch {
|
|
6316
|
+
databaseActionContract = null;
|
|
6317
|
+
}
|
|
6318
|
+
}
|
|
6319
|
+
featureExample = buildDatabaseActionFeatureExample(targets, databaseActionContract);
|
|
6320
|
+
}
|
|
6321
|
+
}
|
|
6068
6322
|
return {
|
|
6069
6323
|
content: [
|
|
6070
6324
|
{
|
|
@@ -6072,6 +6326,8 @@ async function main() {
|
|
|
6072
6326
|
text: JSON.stringify({
|
|
6073
6327
|
payload: generated,
|
|
6074
6328
|
snippet,
|
|
6329
|
+
...(databaseActionContract ? { database_action_contract: databaseActionContract } : {}),
|
|
6330
|
+
...(featureExample ? { feature_example: featureExample } : {}),
|
|
6075
6331
|
}, null, 2),
|
|
6076
6332
|
},
|
|
6077
6333
|
],
|
|
@@ -6084,6 +6340,100 @@ async function main() {
|
|
|
6084
6340
|
};
|
|
6085
6341
|
const schemaHandler = async (args) => {
|
|
6086
6342
|
try {
|
|
6343
|
+
if (args.database_tag) {
|
|
6344
|
+
if (!args.product_tag || !args.database_tag || !args.env_slug) {
|
|
6345
|
+
throw new Error('product_tag, database_tag, and env_slug are required for live database schema discovery');
|
|
6346
|
+
}
|
|
6347
|
+
let action = null;
|
|
6348
|
+
let table = args.table;
|
|
6349
|
+
if (args.action_tag) {
|
|
6350
|
+
const databaseTag = args.database_tag.startsWith(`${args.product_tag}:`)
|
|
6351
|
+
? args.database_tag
|
|
6352
|
+
: `${args.product_tag}:${args.database_tag}`;
|
|
6353
|
+
const fullActionTag = args.action_tag.startsWith(`${databaseTag}:`)
|
|
6354
|
+
? args.action_tag
|
|
6355
|
+
: `${databaseTag}:${args.action_tag}`;
|
|
6356
|
+
const response = await cliHandler({
|
|
6357
|
+
command: `db actions get ${shellArgument(fullActionTag)} --json`,
|
|
6358
|
+
});
|
|
6359
|
+
if (response.isError)
|
|
6360
|
+
return response;
|
|
6361
|
+
const raw = response.content[0]?.type === 'text' ? response.content[0].text : '';
|
|
6362
|
+
try {
|
|
6363
|
+
const parsed = JSON.parse(raw);
|
|
6364
|
+
action = parsed?.data ?? parsed?.result ?? parsed;
|
|
6365
|
+
table = table ?? action?.tableName;
|
|
6366
|
+
}
|
|
6367
|
+
catch {
|
|
6368
|
+
throw new Error(`Database Action metadata was not valid JSON: ${raw}`);
|
|
6369
|
+
}
|
|
6370
|
+
}
|
|
6371
|
+
if (!table) {
|
|
6372
|
+
throw new Error('table is required for direct database schema discovery and could not be inferred from the action');
|
|
6373
|
+
}
|
|
6374
|
+
const key = args.publishable_key || process.env.DUCTAPE_PUBLISHABLE_KEY;
|
|
6375
|
+
if (!key) {
|
|
6376
|
+
throw new Error('Set DUCTAPE_PUBLISHABLE_KEY or pass publishable_key so ductape_schema can fetch synchronized table metadata');
|
|
6377
|
+
}
|
|
6378
|
+
const generated = await generateExecutablePayload({
|
|
6379
|
+
publishable_key: key,
|
|
6380
|
+
product_tag: args.product_tag,
|
|
6381
|
+
env_slug: args.env_slug,
|
|
6382
|
+
operation_family: 'database',
|
|
6383
|
+
method: args.action_tag
|
|
6384
|
+
? String(action?.operation ?? action?.type ?? 'query').toLowerCase()
|
|
6385
|
+
: (args.database_operation ?? 'query'),
|
|
6386
|
+
targets: {
|
|
6387
|
+
database: args.database_tag,
|
|
6388
|
+
table,
|
|
6389
|
+
...(args.action_tag ? { action: args.action_tag } : {}),
|
|
6390
|
+
},
|
|
6391
|
+
include_session: false,
|
|
6392
|
+
execution_context: 'system',
|
|
6393
|
+
schema_mode: 'best_effort',
|
|
6394
|
+
});
|
|
6395
|
+
const databaseSchema = generated?.meta?.schema_context?.database ?? null;
|
|
6396
|
+
const actionContract = args.action_tag
|
|
6397
|
+
? action?.outputContractsByEnvironment?.[args.env_slug] ?? action?.outputContract ?? null
|
|
6398
|
+
: null;
|
|
6399
|
+
const directOutputContracts = {
|
|
6400
|
+
query: { normalizedOutput: '{ data: Row[], count: number, fields?: string[] }', cardinality: 'many' },
|
|
6401
|
+
insert: { normalizedOutput: '{ data: Row[], count: number, insertedIds: unknown[] }', cardinality: 'many' },
|
|
6402
|
+
update: { normalizedOutput: '{ data: Row[], count: number }', cardinality: 'many' },
|
|
6403
|
+
delete: { normalizedOutput: '{ count: number, data?: Row[] }', cardinality: 'many' },
|
|
6404
|
+
upsert: { normalizedOutput: '{ data: Row[], count: number, operation: "inserted" | "updated" }', cardinality: 'many' },
|
|
6405
|
+
aggregate: { normalizedOutput: 'Record<aggregationAlias, number | unknown>', cardinality: 'one' },
|
|
6406
|
+
raw: { normalizedOutput: '{ data: Row[], count: number, fields?: string[], rowsAffected?: number }', cardinality: 'many' },
|
|
6407
|
+
};
|
|
6408
|
+
const outputContract = actionContract ?? directOutputContracts[args.database_operation ?? 'query'];
|
|
6409
|
+
const rowSchema = actionContract?.rowSchema ?? databaseSchema?.fields ?? {};
|
|
6410
|
+
return {
|
|
6411
|
+
content: [{
|
|
6412
|
+
type: 'text',
|
|
6413
|
+
text: JSON.stringify({
|
|
6414
|
+
scope: args.action_tag ? 'database_action' : 'database_primitive',
|
|
6415
|
+
product_tag: args.product_tag,
|
|
6416
|
+
env_slug: args.env_slug,
|
|
6417
|
+
database_tag: args.database_tag,
|
|
6418
|
+
table,
|
|
6419
|
+
operation: args.action_tag
|
|
6420
|
+
? action?.operation ?? action?.type
|
|
6421
|
+
: args.database_operation ?? 'query',
|
|
6422
|
+
input_contract: args.action_tag ? action?.data ?? [] : generated?.payload?.input,
|
|
6423
|
+
output_contract: outputContract,
|
|
6424
|
+
row_schema: rowSchema,
|
|
6425
|
+
database_schema: databaseSchema,
|
|
6426
|
+
action: args.action_tag ? action : undefined,
|
|
6427
|
+
warnings: generated?.meta?.schema_warnings ?? [],
|
|
6428
|
+
discovery_only: true,
|
|
6429
|
+
executed_action: false,
|
|
6430
|
+
guidance: outputContract?.normalizedOutput
|
|
6431
|
+
? 'Use output_contract for the envelope and row_schema for data item fields.'
|
|
6432
|
+
: 'Do not infer row fields or an action envelope when metadata is absent. Sync schema metadata and retry ductape_schema.',
|
|
6433
|
+
}, null, 2),
|
|
6434
|
+
}],
|
|
6435
|
+
};
|
|
6436
|
+
}
|
|
6087
6437
|
const liveActionScope = [args.product_tag, args.app_tag, args.action_tag];
|
|
6088
6438
|
if (liveActionScope.some(Boolean)) {
|
|
6089
6439
|
if (!liveActionScope.every(Boolean)) {
|
|
@@ -6215,11 +6565,18 @@ async function main() {
|
|
|
6215
6565
|
}, snippetGenerateHandler);
|
|
6216
6566
|
server.registerTool('ductape_schema', {
|
|
6217
6567
|
title: 'Ductape Asset Schema',
|
|
6218
|
-
description: 'Returns a compact method index, one targeted asset schema,
|
|
6568
|
+
description: 'Returns a compact method index, one targeted asset schema, one complete live app-action contract, ' +
|
|
6569
|
+
'or a live synchronized Database Action/direct database row contract. ' +
|
|
6219
6570
|
'Call with module="app" or module="product" first to list method keys, then call again with ' +
|
|
6220
6571
|
'module and method (for example method="databases.create") for the complete field schema. ' +
|
|
6221
6572
|
'For one linked app action, call with product_tag, app_tag, and action_tag; do not fetch a full app catalogue. ' +
|
|
6573
|
+
'For a saved Database Action, call with product_tag, env_slug, database_tag, and action_tag. ' +
|
|
6574
|
+
'For a direct database call, call with product_tag, env_slug, database_tag, table, and database_operation. ' +
|
|
6575
|
+
'Database results distinguish output_contract (the operation envelope) from row_schema (fields inside data rows). ' +
|
|
6576
|
+
'This lookup is discovery-only and never executes the database action or primitive. ' +
|
|
6222
6577
|
'Avoid calling without module unless you explicitly need the entire manifest.\n\n' +
|
|
6578
|
+
'ALWAYS call the live database form before generating code that reads fields from a saved Database Action ' +
|
|
6579
|
+
'or direct query result. If row_schema is unavailable, do not guess fields; sync the database schema and retry. ' +
|
|
6223
6580
|
'ALWAYS call this before constructing a file for "resources <type> create" or any cloud ' +
|
|
6224
6581
|
'import/provision operation — field shapes are not guessable from context.\n\n' +
|
|
6225
6582
|
'Conditional fields: some fields are returned as oneOf (an array of variant shapes). ' +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ductape/mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.34",
|
|
4
4
|
"description": "MCP server that exposes Ductape SDK operations via the backend proxy",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsc",
|
|
18
|
-
"test": "npm run build && node scripts/check-cli-command-security.mjs && node scripts/check-frontend-analytics-guidance.mjs && node scripts/check-events-discovery.mjs && node scripts/check-schema-fallback.mjs && node scripts/check-paystack-action-schema.mjs && node scripts/check-portable-functions.mjs && node scripts/check-feature-control-flow.mjs && node scripts/check-project-link-guidance.mjs && node scripts/check-graph-vector-projection-guidance.mjs && node scripts/check-asset-file-guidance.mjs",
|
|
18
|
+
"test": "npm run build && node scripts/check-cli-command-security.mjs && node scripts/check-frontend-analytics-guidance.mjs && node scripts/check-events-discovery.mjs && node scripts/check-schema-fallback.mjs && node scripts/check-paystack-action-schema.mjs && node scripts/check-portable-functions.mjs && node scripts/check-feature-control-flow.mjs && node scripts/check-project-link-guidance.mjs && node scripts/check-graph-vector-projection-guidance.mjs && node scripts/check-asset-file-guidance.mjs && node scripts/check-database-action-contract-guidance.mjs && node scripts/check-runtime-sync-guidance.mjs",
|
|
19
19
|
"start": "node dist/index.js",
|
|
20
20
|
"dev": "tsx src/index.ts"
|
|
21
21
|
},
|