@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.
Files changed (2) hide show
  1. package/dist/index.js +370 -13
  2. 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: "table_or_collection_name" }. ' +
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
- workspace_id: process.env.DUCTAPE_WORKSPACE_ID!,
1311
- user_id: process.env.DUCTAPE_USER_ID!,
1312
- public_key: process.env.DUCTAPE_PUBLIC_KEY!,
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
- const payload = ${toPrettyJson(payload)};
1344
+ await ductape.ready();
1318
1345
  const args = ${toPrettyJson(invocationArgs)};
1319
- const result = await ductape.${callPath}(args);
1320
- return { payload, result };
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
- Each service makes a single bootstrap call at first use; results are cached in BootstrapCache
3050
- (Redis when available). This avoids repeated round-trips in high-frequency paths.
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.count, 0), {
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, or one complete live app-action contract. ' +
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.32",
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
  },