@ductape/mcp 0.2.31 → 0.2.33

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 +398 -16
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -945,6 +945,9 @@ ALL params are passed as a JSON array in positional order matching the SDK signa
945
945
  // ctx.input – typed runtime input; always compiles to $Input{} operators
946
946
  // ctx.sampleInput – compile-time sample for loop/branch discovery only
947
947
  // ctx.step(tag, fn, rollback?, opts?) – define a durable step
948
+ // ctx.when.eq/ne/gt/gte/lt/lte/truthy/falsy(...) – build portable runtime conditions
949
+ // ctx.branch(condition, { then, else? }) – record both paths as conditioned steps
950
+ // ctx.each(ctx.sampleInput.items, async (item, index) => ...) – deterministic compile-time expansion
948
951
  // ctx.api.run({ app, action, input }) – call an app action (NOT 'event' -- that field name
949
952
  // only applies to ctx.database/ctx.notification/ctx.storage below, never ctx.api/ctx.action)
950
953
  // ctx.database.execute({ database, action, input }) for a saved database action
@@ -1125,7 +1128,8 @@ const payloadGenerateInputSchema = z.object({
1125
1128
  targets: z.record(z.any()).optional().describe('Identifies the specific operation to generate a payload for. ' +
1126
1129
  'For actions: { app: "app_tag", action: "action_tag" }. ' +
1127
1130
  'For features: { feature: "feature_tag" }. ' +
1128
- 'For databases: { database: "db_tag", table: "table_or_collection_name" }. ' +
1131
+ 'For databases: { database: "db_tag", table?: "table_or_collection_name", action?: "action_tag" }. ' +
1132
+ ' When generating code for a saved action, action is required and its static outputContract must be inspected. ' +
1129
1133
  ' Providing table is strongly recommended — the generator scans all actions configured for that table, ' +
1130
1134
  ' aggregates field definitions (name, type, required, sample value), and returns them in ' +
1131
1135
  ' meta.schema_context.database.fields. The where/data placeholders in the payload are also ' +
@@ -1158,11 +1162,16 @@ const snippetGenerateInputSchema = payloadGenerateInputSchema.extend({
1158
1162
  language: z.enum(['typescript', 'python']).default('typescript'),
1159
1163
  });
1160
1164
  const schemaInputSchema = z.object({
1165
+ publishable_key: z.string().optional().describe('Optional publishable key for live database schema lookup. Omit when DUCTAPE_PUBLISHABLE_KEY is configured.'),
1161
1166
  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.'),
1162
1167
  method: z.string().optional().describe('Optional method key such as "databases.create" or "notifications.update". Use with module to return only that method schema.'),
1163
1168
  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.'),
1164
1169
  app_tag: z.string().optional().describe('For live app-action discovery, the linked app tag. Must be supplied with product_tag and action_tag.'),
1165
- action_tag: z.string().optional().describe('For live app-action discovery, the exact action tag whose complete contract should be returned.'),
1170
+ action_tag: z.string().optional().describe('For live app-action or database-action discovery, the exact action tag whose complete contract should be returned.'),
1171
+ database_tag: z.string().optional().describe('For live database discovery, the database component tag. Requires product_tag and env_slug.'),
1172
+ env_slug: z.string().optional().describe('For live database discovery, the environment whose synchronized table schema and action contract should be used.'),
1173
+ 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.'),
1174
+ 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.'),
1166
1175
  });
1167
1176
  const marketplaceDiscoverInputSchema = z.object({
1168
1177
  query: z.string().min(1).describe('Capability or provider search, for example "payments" or "paystack".'),
@@ -1348,6 +1357,44 @@ function buildSnippet(language, payload, operationFamily, method) {
1348
1357
  ? buildPythonSnippet(payload, operationFamily, method)
1349
1358
  : buildTypeScriptSnippet(payload, operationFamily, method);
1350
1359
  }
1360
+ function buildDatabaseActionFeatureExample(targets, contract) {
1361
+ const database = targets.database ?? targets.database_tag ?? '<database-tag>';
1362
+ const action = targets.action ?? targets.action_tag ?? '<action-tag>';
1363
+ if (!contract?.normalizedOutput) {
1364
+ return `// Output contract unknown: do not infer a row, array, or envelope.\n` +
1365
+ `// Sync the table schema and fetch the Database Action contract before generating this Feature.`;
1366
+ }
1367
+ const operation = String(contract.operation ?? '').toUpperCase();
1368
+ if (operation === 'QUERY' || operation === 'READ') {
1369
+ return `type DatabaseActionResult = { data: Record<string, unknown>[]; count: number; fields?: string[] };\n\n` +
1370
+ `const queryResult = await ctx.step("${action}", () =>\n` +
1371
+ ` ctx.database.execute<DatabaseActionResult>({\n` +
1372
+ ` database: ${JSON.stringify(database)},\n` +
1373
+ ` action: ${JSON.stringify(action)},\n` +
1374
+ ` input: { /* values from ctx.input */ },\n` +
1375
+ ` }),\n` +
1376
+ `);\n\n` +
1377
+ `await ctx.branch(ctx.when.eq(ctx.transform.length(queryResult.data), 0), {\n` +
1378
+ ` then: async () => { /* handle successful empty query */ },\n` +
1379
+ ` else: async () => { const row = queryResult.data[0]; /* consume validated row */ },\n` +
1380
+ `});`;
1381
+ }
1382
+ if ((operation === 'INSERT' || operation === 'CREATE') && contract.createdRecordPath === 'data[0]') {
1383
+ return `const insertResult = await ctx.step("${action}", () =>\n` +
1384
+ ` ctx.database.execute<{ data: Record<string, unknown>[]; count: number; insertedIds: unknown[] }>({\n` +
1385
+ ` database: ${JSON.stringify(database)}, action: ${JSON.stringify(action)}, input: { /* values */ },\n` +
1386
+ ` }),\n` +
1387
+ `);\n` +
1388
+ `const createdRow = insertResult.data[0]; // No redundant follow-up query: contract returns rows.`;
1389
+ }
1390
+ return `// ${operation} returns ${contract.returnedRows ?? 'unknown'} rows.\n` +
1391
+ `const result = await ctx.step("${action}", () =>\n` +
1392
+ ` ctx.database.execute({ database: ${JSON.stringify(database)}, action: ${JSON.stringify(action)}, input: { /* values */ } }),\n` +
1393
+ `);\n` +
1394
+ (contract.followUpQueryRequired
1395
+ ? `// This contract returns mutation metadata only; add an explicit saved QUERY action before consuming a row.`
1396
+ : `// Consume only fields declared by normalizedOutput.`);
1397
+ }
1351
1398
  // ─── CLI helpers ─────────────────────────────────────────────────────────────
1352
1399
  // Per-process cache: avoids re-running whoami / workspaces use on every call.
1353
1400
  let authState = 'unknown';
@@ -2472,12 +2519,124 @@ ctx.database.execute inside a Feature step invokes a saved action through its \`
2472
2519
  Direct ctx.database.query/insert/update/delete calls retain their normal {table, where, data, ...}
2473
2520
  shape and do not resolve a saved action.
2474
2521
 
2522
+ HARDENED DIRECT DATABASE PRIMITIVE CONTRACTS:
2523
+ Direct calls also return typed adapter envelopes; they do not return a raw row, raw array,
2524
+ boolean, or arbitrary T. Derive row fields from the selected table schema and use these exact
2525
+ normalized contracts:
2526
+ ctx.database.query<Row>(...) → { data: Row[], count: number, fields?: string[] }
2527
+ ctx.database.insert<Row>(...) → { data: Row[], count: number, insertedIds: unknown[] }
2528
+ ctx.database.update<Row>(...) → { data: Row[], count: number }
2529
+ ctx.database.delete(...) → { count: number, data?: Row[] }
2530
+ ctx.database.upsert<Row>(...) → { data: Row[], count: number, operation: "inserted"|"updated" }
2531
+ ctx.database.raw<Row>(...) → { data: Row[], count: number, fields?: string[], rowsAffected?: number }
2532
+ ctx.database.aggregate(...) → an alias-keyed aggregate object declared by operations; it is
2533
+ NOT a row envelope unless the selected adapter contract says so.
2534
+
2535
+ Never generate queryResult[0], queryResult.length, a boolean check on deleteResult, or direct
2536
+ field access such as insertResult.id. Query rows live at queryResult.data; delete success/count
2537
+ is deleteResult.count; inserted/updated rows exist in mutationResult.data only when returning is
2538
+ true. INSERT always includes insertedIds, but an inserted ID is not a complete row.
2539
+
2540
+ Direct primitive contracts are derived from the explicit method, adapter/version, table schema,
2541
+ and call options such as returning, returningColumns, select, limit, and aggregation aliases.
2542
+ Never infer row fields from domain expectations. If table schema is missing, call static schema
2543
+ discovery/sync and mark row fields unknown; do not execute the operation merely to learn shape.
2544
+
2545
+ DIRECT FEATURE-SAFE EXAMPLES:
2546
+ const queryResult = await ctx.step("find-customer", () =>
2547
+ ctx.database.query<Customer>({
2548
+ database: "payment-processing", table: "customers",
2549
+ where: { email: ctx.input.customerEmail }, limit: 1,
2550
+ }),
2551
+ );
2552
+ await ctx.branch(ctx.when.eq(ctx.transform.length(queryResult.data), 0), {
2553
+ then: async () => { /* create */ },
2554
+ else: async () => { const customer = queryResult.data[0]; /* consume */ },
2555
+ });
2556
+
2557
+ const insertResult = await ctx.step("create-customer", () =>
2558
+ ctx.database.insert<Customer>({
2559
+ database: "payment-processing", table: "customers", data: { /* fields */ }, returning: true,
2560
+ }),
2561
+ );
2562
+ // returning:true declares created rows at insertResult.data; do not re-query.
2563
+ const createdCustomer = insertResult.data[0];
2564
+
2565
+ Empty { data: [], count: 0 } is successful. Thrown/explicit failure is a failed step. A successful
2566
+ value that violates the primitive's declared envelope is a platform contract violation and must
2567
+ fail at the producing step boundary. Never let a missing required row field flow to an app action.
2568
+
2475
2569
  REUSE BEFORE CREATE:
2476
2570
  1. ductape_cli("db actions list --database product_tag:database_tag --json")
2477
2571
  2. Inspect likely matches with ductape_cli("db actions get product_tag:database_tag:action_tag --json").
2478
2572
  3. Reuse the matching tag with database.execute/ctx.database.execute when operation, table,
2479
2573
  template, parameters, and result semantics match. Create only when no exact functional match exists.
2480
2574
 
2575
+ OUTPUT CONTRACTS ARE MANDATORY DISCOVERY METADATA:
2576
+ Every action returned by databases.action.fetch/fetchAll includes outputContract and
2577
+ outputContractsByEnvironment. Read the contract for the target environment before writing a
2578
+ Feature. It contains input, normalizedOutput, rowSchema, cardinality, returnedRows,
2579
+ returnsAffectedRows, createdRecordPath, followUpQueryRequired, adapter, and adapterOutput.
2580
+
2581
+ Never infer a Database Action's output shape from its operation name, generic SDK knowledge,
2582
+ or application-domain expectations. Derive it from the database adapter, action definition,
2583
+ table schema metadata, and action outputContract. If the contract confidence is "unknown" or
2584
+ normalizedOutput is null, state that the output is unknown and request platform metadata; do
2585
+ not invent a raw row, array, envelope, or field. Static discovery must never execute the action.
2586
+
2587
+ MANDATORY DUCTAPE_SCHEMA ROW LOOKUP:
2588
+ The normalized envelope does NOT reveal the fields inside Row. Before generating code that reads
2589
+ result.data[n].field (or evaluates a row field in a Feature branch), call one of these exact forms:
2590
+ Saved Database Action:
2591
+ ductape_schema({ product_tag: "<product_tag>", env_slug: "<env_slug>",
2592
+ database_tag: "<database_tag>", action_tag: "<action_tag>" })
2593
+ Direct database query/primitive:
2594
+ ductape_schema({ product_tag: "<product_tag>", env_slug: "<env_slug>",
2595
+ database_tag: "<database_tag>", table: "<table>", database_operation: "query" })
2596
+ Use output_contract only for the saved action's operation-specific envelope. Use row_schema for
2597
+ the contents of each result.data item for BOTH saved actions and direct calls. Projection/select
2598
+ may narrow those fields further. If row_schema is empty or warnings report missing synchronized
2599
+ metadata, run \`ductape db schema push --db <database_tag> --env <env_slug>\`, retry
2600
+ ductape_schema, and keep row fields unknown until it succeeds. Never execute the query/action to
2601
+ discover its shape, and never substitute a generic product asset schema for this live lookup.
2602
+
2603
+ NORMALIZED ENVELOPES DIFFER BY OPERATION:
2604
+ QUERY → { data: Row[], count: number, fields?: string[] }
2605
+ INSERT → { data: Row[], count: number, insertedIds: unknown[] }
2606
+ UPDATE → { data: Row[], count: number }
2607
+ DELETE → { count: number, data?: Row[] }
2608
+ RAW/EXECUTE → { data: Row[], count: number, fields?: string[], rowsAffected?: number }
2609
+ returning controls whether mutation data contains rows. Do not describe all operations as the
2610
+ same envelope. Adapter-native details belong in adapterOutput; Feature code uses normalizedOutput.
2611
+
2612
+ FEATURE-SAFE QUERY PATTERN:
2613
+ type FindCustomerResult = { data: Customer[]; count: number; fields?: string[] };
2614
+ const queryResult = await ctx.step("find-customer", () =>
2615
+ ctx.database.execute<FindCustomerResult>({
2616
+ database: "payment-processing",
2617
+ action: "find-customer-by-email",
2618
+ input: { email: ctx.input.customerEmail },
2619
+ }),
2620
+ );
2621
+ await ctx.branch(ctx.when.eq(ctx.transform.length(queryResult.data), 0), {
2622
+ then: async () => { /* create */ },
2623
+ else: async () => {
2624
+ const customer = queryResult.data[0];
2625
+ /* consume customer only inside this structurally valid branch */
2626
+ },
2627
+ });
2628
+ Never generate queryResult[0] or ctx.transform.length(queryResult) for an envelope contract.
2629
+ { data: [], count: 0 } is a successful empty query, not a failed action.
2630
+
2631
+ MUTATION FLOW:
2632
+ Inspect returnedRows, createdRecordPath, and followUpQueryRequired. If INSERT returns rows, use
2633
+ insertResult.data[0] directly and do not add a redundant resolve query. If it returns metadata
2634
+ only, explain that fact and add an explicit follow-up query. Never allow an undefined required
2635
+ id/email to reach a downstream app/provider action: validate it or make that step structurally
2636
+ dependent on queryResult.data[0] or the declared createdRecordPath. A thrown/failed response is
2637
+ a failed step; a successful value that violates normalizedOutput is a platform contract violation
2638
+ and must fail at the producing step boundary.
2639
+
2481
2640
  Create/update/delete are ADMINISTRATIVE (access key) — FORBIDDEN through ductape_execute
2482
2641
  (publishable key). Use ductape_cli, same as every other admin resource:
2483
2642
  ductape_cli("db actions create --action-file ductape/database/actions/<action-tag>.action.json")
@@ -3373,6 +3532,20 @@ CONFIGURATION BOUNDARY
3373
3532
  Workbench is also supported. Never route their administrative create/update methods through
3374
3533
  ductape_execute: its publishable-key runtime proxy will fail.
3375
3534
 
3535
+ CLI COMMANDS (aliases are normalized; use these exact shapes):
3536
+ ductape resources feature list --product <product> --json
3537
+ ductape resources feature get -t <feature> --product <product> --json
3538
+ ductape resources quota list --product <product> --json
3539
+ ductape resources quota get -t <quota> --product <product> --json
3540
+ ductape resources fallback list --product <product> --json
3541
+ ductape resources fallback get -t <fallback> --product <product> --json
3542
+ ductape resources health list --product <product> --json
3543
+ ductape resources health get -t <healthcheck> --product <product> --json
3544
+ Create/update use the same resource type plus -f <asset.json>. Singular/plural feature(s),
3545
+ fallback(s), and healthcheck(s) aliases are accepted, but canonical generated commands should
3546
+ use feature, quota, fallback, and health. These commands are administrative catalogue CRUD;
3547
+ runtime run/execute operations belong to the SDK or authenticated execution surface.
3548
+
3376
3549
  QUOTAS — weighted/provider-capacity routing pools (NOT request rate limiting):
3377
3550
  Workbench definition shape:
3378
3551
  {
@@ -3929,15 +4102,83 @@ STEP 6 — WRITE the feature into the project codebase
3929
4102
  script does, invoked only through the explicit CLI command.
3930
4103
 
3931
4104
  STEP 7 — HANDLE conditionals, loops, and branching (when applicable)
3932
- Branch on step result (early return in handler):
3933
- Add branchOverrides: { stepTag: { field: value } } so all branches are recorded
3934
- At runtime the executor evaluates the real result and skips or runs later steps accordingly
4105
+ MANDATORY CONTROL-FLOW RULES:
4106
+ 1. A stored Feature executes its compiled steps. The JavaScript handler is NOT rerun at runtime.
4107
+ 2. Never use if/else, switch, ?:, ??, &&, ||, optional chaining, map, filter, find, some,
4108
+ every, reduce, for, for..of, forEach, or while on a ctx.step() result or ctx.input proxy.
4109
+ JavaScript would evaluate the recording proxy, not the future runtime value.
4110
+ 3. Use ctx.branch(ctx.when.*, { then, else }) for runtime decisions.
4111
+ 4. Use ctx.each only with a concrete ctx.sampleInput array to expand a fixed step graph.
4112
+ 5. Put runtime-sized collection algorithms and arbitrary business logic in a registered
4113
+ portable Function, then invoke it through ctx.functions inside a step.
4114
+ 6. Direct Date.now() and Math.random() are forbidden in handlers. Use ctx.transform.now(),
4115
+ ctx.transform.uuid(), and ctx.transform.concat/replace/substring/upper/lower/trim so values
4116
+ are generated at execution time. Use a portable Function for domain-specific generators.
4117
+ 7. Never use branchOverrides in newly generated code. It exists only to migrate old handlers.
4118
+
4119
+ Prefer explicit portable branching for step results:
4120
+ const result = await ctx.step("find", () => ctx.database.execute(...));
4121
+ await ctx.branch(ctx.when.eq(ctx.transform.length(result.data), 0), {
4122
+ then: () => ctx.step("create", () => ctx.database.execute(...)),
4123
+ else: () => ctx.step("reuse", () => ctx.database.execute(...)),
4124
+ });
4125
+ → ctx.when also supports ne, gt, gte, lt, lte, truthy, falsy, and/or.
4126
+ → Do not use ordinary if/else, ternary, or ?? directly on an unresolved step result.
4127
+ → branchOverrides remains a compatibility escape hatch for older Feature source.
4128
+ → If both paths must feed a later common step, do not assume ctx.branch returns a selected
4129
+ value. Either record the downstream operation inside each path, persist a shared result that
4130
+ the later step can read, or move the find-or-create algorithm into one portable Function.
3935
4131
  Loop over input array:
3936
- → Add recordInput: { items: [{ id: "1" }, { id: "2" }] } and iterate ctx.sampleInput.items
4132
+ → Add recordInput: { items: [{ id: "1" }, { id: "2" }] } and call
4133
+ ctx.each(ctx.sampleInput.items, async (item, index) => { ... })
3937
4134
  → Use unique step tags per iteration (e.g. "process-" + item.id)
4135
+ → map/filter/find/indexing on ctx.sampleInput are compile-time graph discovery only.
4136
+ → Runtime-sized map/filter/find/some/every and arbitrary loops belong in a registered portable
4137
+ Function invoked through ctx.functions; never guess or freeze their results during recording.
3938
4138
  Switch/if-else on feature input values:
3939
- Add recordScenarios: [{ type: "a" }, { type: "b" }] — handler runs once per scenario
3940
- Only the scenario whose input matches runs at execution time
4139
+ Prefer ctx.branch(ctx.when.eq(ctx.input.type, "a"), { then, else }).
4140
+ recordScenarios is for legacy compile-time graph discovery, not a runtime branch primitive.
4141
+
4142
+ SAFE COPY-READY PATTERNS:
4143
+ Runtime input branch:
4144
+ await ctx.branch(ctx.when.eq(ctx.input.kind, "refund"), {
4145
+ then: () => ctx.step("refund", () => ctx.api.run({ app, action: "refund", input: {...} })),
4146
+ else: () => ctx.step("charge", () => ctx.api.run({ app, action: "charge", input: {...} })),
4147
+ });
4148
+ Nested/indexed result check:
4149
+ await ctx.branch(ctx.when.eq(search.data.length, 0), { then: ..., else: ... });
4150
+ Compound condition:
4151
+ await ctx.branch(ctx.when.and(
4152
+ ctx.when.eq(result.status, "ready"),
4153
+ ctx.when.gt(result.count, 0),
4154
+ ), { then: ..., else: ... });
4155
+ Runtime payment/reference string (never frozen at sync time):
4156
+ const reference = ctx.transform.concat(
4157
+ "pay_", ctx.transform.now(), "_", ctx.transform.uuid(),
4158
+ );
4159
+ await ctx.step("charge", () => ctx.fallback.execute({
4160
+ fallback: "charge-payment", input: { reference, ... },
4161
+ }));
4162
+ Fixed graph expansion:
4163
+ recordInput: { regions: ["ng", "gh"] },
4164
+ handler: async (ctx) => ctx.each(ctx.sampleInput.regions, async (region, index) => {
4165
+ await ctx.step("sync-region-" + index, () => ctx.functions.invoke(syncFn, "run", { region }));
4166
+ });
4167
+
4168
+ UNSAFE — NEVER GENERATE:
4169
+ if (result.count === 0) await ctx.step(...);
4170
+ const customer = found[0] ?? await ctx.step(...);
4171
+ ctx.input.items.map(item => ctx.step(...));
4172
+ for (const item of ctx.input.items) await ctx.step(...);
4173
+ const reference = "pay_" + Date.now() + "_" + Math.random();
4174
+
4175
+ BEFORE SYNCING A FEATURE:
4176
+ → Confirm every runtime decision uses ctx.branch + ctx.when.
4177
+ → Confirm every ctx.step tag is unique across both paths and every expanded iteration.
4178
+ → Confirm no runtime proxy is consumed by native JavaScript control flow or array iteration.
4179
+ → Confirm every ctx.step contains a portable ctx component/function call.
4180
+ → Compile locally, inspect schema.steps, and verify both branch paths, condition, depends_on,
4181
+ and operator-valued inputs are present before persisting.
3941
4182
 
3942
4183
  STEP 8 — SET rollbacks for reversible steps
3943
4184
  Any step that allocates a resource should undo it if a later step fails.
@@ -4047,16 +4288,18 @@ Feature statuses: pending | running | completed | failed | rolled_back | rolling
4047
4288
 
4048
4289
  ━━━ FEATURE RECORDING SEMANTICS ━━━
4049
4290
 
4050
- When you call features.define({ handler }), the handler runs TWICE:
4291
+ When you call features.define({ handler }), there are two distinct phases, but the handler itself
4292
+ runs only during compilation/recording:
4051
4293
 
4052
4294
  1. RECORDING PHASE (at define time) — handler is called with a RecordingContext.
4053
4295
  All ctx.step() calls return lightweight proxy objects, not real data.
4054
4296
  This phase captures the step graph: which steps exist, their types, tags, and declared
4055
4297
  inputs/outputs. No real API calls, DB queries, or side effects occur.
4056
4298
  Arbitrary JS code OUTSIDE ctx.step() ALSO runs during recording — with proxy values.
4057
- For loops: supply recordInput and iterate ctx.sampleInput so all iterations are recorded.
4299
+ For loops: supply recordInput and use ctx.each(ctx.sampleInput.items, ...) so all iterations are recorded.
4058
4300
  ctx.input is always the runtime operator surface and must never expose recordInput literals.
4059
- For branches: use branchOverrides so each path is captured.
4301
+ For branches: use ctx.branch(ctx.when.*, { then, else }) so both paths are captured. Use
4302
+ branchOverrides only for compatibility with existing Feature handlers.
4060
4303
  Never make an authorization, validation, tenancy, or other security decision by branching on
4061
4304
  ctx.input during recording. Use ctx.sampleInput only to discover graph shape; enforce security
4062
4305
  invariants inside a runtime portable Function or recorded step.
@@ -4066,11 +4309,15 @@ When you call features.define({ handler }), the handler runs TWICE:
4066
4309
  integrations and project readiness hooks must not start HTTP listeners, provider probes,
4067
4310
  schedulers, or Event consumers. Apply the filter before booting unrelated service modules.
4068
4311
 
4069
- 2. EXECUTION PHASE (at runtime) — handler is called with a real ExecutionContext.
4070
- ctx.step() actually executes. All real Ductape component calls happen.
4071
- Arbitrary JS logic (math, string ops, conditionals on step results) runs for real.
4312
+ 2. EXECUTION PHASE (at runtime) — the stored schema.steps graph is interpreted by FeatureExecutor.
4313
+ The original JavaScript handler is NOT called. Step inputs and conditions resolve operators
4314
+ such as $Input{}, $Sequence{}, $Step{}, and $Now against runtime state. Only recorded portable
4315
+ component/function operations execute.
4072
4316
 
4073
- Implication: put all meaningful business logic INSIDE ctx.step() handlers, not in the
4317
+ Implication: arbitrary JavaScript control flow never becomes runtime behavior merely because it
4318
+ appears in the handler. Express runtime branches through ctx.branch/ctx.when, fixed graph expansion
4319
+ through ctx.each(ctx.sampleInput...), and runtime-sized algorithms through ctx.functions. Put all
4320
+ meaningful business logic INSIDE recorded portable operations, not in the
4074
4321
  outer handler body. Code in the outer body runs during recording with proxy values and
4075
4322
  may behave unexpectedly (e.g. typeof proxy === 'object' is true but .someField is a proxy).
4076
4323
 
@@ -5974,6 +6221,38 @@ async function main() {
5974
6221
  const generated = addSessionAwarenessMetadata(await generateExecutablePayload({ ...args, include_session: includeSession, publishable_key: key }), args);
5975
6222
  const payload = generated?.payload ?? {};
5976
6223
  const snippet = buildSnippet(args.language, payload, args.operation_family, args.method);
6224
+ let databaseActionContract = null;
6225
+ let featureExample;
6226
+ if (args.operation_family === 'database') {
6227
+ const targets = (args.targets ?? {});
6228
+ const action = targets.action ?? targets.action_tag;
6229
+ const database = targets.database ?? targets.database_tag;
6230
+ if (action && database) {
6231
+ const databaseTag = String(database).startsWith(`${args.product_tag}:`)
6232
+ ? String(database)
6233
+ : `${args.product_tag}:${database}`;
6234
+ const fullActionTag = String(action).startsWith(`${databaseTag}:`)
6235
+ ? String(action)
6236
+ : `${databaseTag}:${action}`;
6237
+ const response = await cliHandler({
6238
+ command: `db actions get ${shellArgument(fullActionTag)} --json`,
6239
+ });
6240
+ if (!response.isError) {
6241
+ const raw = response.content[0]?.type === 'text' ? response.content[0].text : '';
6242
+ try {
6243
+ const parsed = JSON.parse(raw);
6244
+ const definition = parsed?.data ?? parsed?.result ?? parsed;
6245
+ databaseActionContract = definition?.outputContractsByEnvironment?.[args.env_slug]
6246
+ ?? definition?.outputContract
6247
+ ?? null;
6248
+ }
6249
+ catch {
6250
+ databaseActionContract = null;
6251
+ }
6252
+ }
6253
+ featureExample = buildDatabaseActionFeatureExample(targets, databaseActionContract);
6254
+ }
6255
+ }
5977
6256
  return {
5978
6257
  content: [
5979
6258
  {
@@ -5981,6 +6260,8 @@ async function main() {
5981
6260
  text: JSON.stringify({
5982
6261
  payload: generated,
5983
6262
  snippet,
6263
+ ...(databaseActionContract ? { database_action_contract: databaseActionContract } : {}),
6264
+ ...(featureExample ? { feature_example: featureExample } : {}),
5984
6265
  }, null, 2),
5985
6266
  },
5986
6267
  ],
@@ -5993,6 +6274,100 @@ async function main() {
5993
6274
  };
5994
6275
  const schemaHandler = async (args) => {
5995
6276
  try {
6277
+ if (args.database_tag) {
6278
+ if (!args.product_tag || !args.database_tag || !args.env_slug) {
6279
+ throw new Error('product_tag, database_tag, and env_slug are required for live database schema discovery');
6280
+ }
6281
+ let action = null;
6282
+ let table = args.table;
6283
+ if (args.action_tag) {
6284
+ const databaseTag = args.database_tag.startsWith(`${args.product_tag}:`)
6285
+ ? args.database_tag
6286
+ : `${args.product_tag}:${args.database_tag}`;
6287
+ const fullActionTag = args.action_tag.startsWith(`${databaseTag}:`)
6288
+ ? args.action_tag
6289
+ : `${databaseTag}:${args.action_tag}`;
6290
+ const response = await cliHandler({
6291
+ command: `db actions get ${shellArgument(fullActionTag)} --json`,
6292
+ });
6293
+ if (response.isError)
6294
+ return response;
6295
+ const raw = response.content[0]?.type === 'text' ? response.content[0].text : '';
6296
+ try {
6297
+ const parsed = JSON.parse(raw);
6298
+ action = parsed?.data ?? parsed?.result ?? parsed;
6299
+ table = table ?? action?.tableName;
6300
+ }
6301
+ catch {
6302
+ throw new Error(`Database Action metadata was not valid JSON: ${raw}`);
6303
+ }
6304
+ }
6305
+ if (!table) {
6306
+ throw new Error('table is required for direct database schema discovery and could not be inferred from the action');
6307
+ }
6308
+ const key = args.publishable_key || process.env.DUCTAPE_PUBLISHABLE_KEY;
6309
+ if (!key) {
6310
+ throw new Error('Set DUCTAPE_PUBLISHABLE_KEY or pass publishable_key so ductape_schema can fetch synchronized table metadata');
6311
+ }
6312
+ const generated = await generateExecutablePayload({
6313
+ publishable_key: key,
6314
+ product_tag: args.product_tag,
6315
+ env_slug: args.env_slug,
6316
+ operation_family: 'database',
6317
+ method: args.action_tag
6318
+ ? String(action?.operation ?? action?.type ?? 'query').toLowerCase()
6319
+ : (args.database_operation ?? 'query'),
6320
+ targets: {
6321
+ database: args.database_tag,
6322
+ table,
6323
+ ...(args.action_tag ? { action: args.action_tag } : {}),
6324
+ },
6325
+ include_session: false,
6326
+ execution_context: 'system',
6327
+ schema_mode: 'best_effort',
6328
+ });
6329
+ const databaseSchema = generated?.meta?.schema_context?.database ?? null;
6330
+ const actionContract = args.action_tag
6331
+ ? action?.outputContractsByEnvironment?.[args.env_slug] ?? action?.outputContract ?? null
6332
+ : null;
6333
+ const directOutputContracts = {
6334
+ query: { normalizedOutput: '{ data: Row[], count: number, fields?: string[] }', cardinality: 'many' },
6335
+ insert: { normalizedOutput: '{ data: Row[], count: number, insertedIds: unknown[] }', cardinality: 'many' },
6336
+ update: { normalizedOutput: '{ data: Row[], count: number }', cardinality: 'many' },
6337
+ delete: { normalizedOutput: '{ count: number, data?: Row[] }', cardinality: 'many' },
6338
+ upsert: { normalizedOutput: '{ data: Row[], count: number, operation: "inserted" | "updated" }', cardinality: 'many' },
6339
+ aggregate: { normalizedOutput: 'Record<aggregationAlias, number | unknown>', cardinality: 'one' },
6340
+ raw: { normalizedOutput: '{ data: Row[], count: number, fields?: string[], rowsAffected?: number }', cardinality: 'many' },
6341
+ };
6342
+ const outputContract = actionContract ?? directOutputContracts[args.database_operation ?? 'query'];
6343
+ const rowSchema = actionContract?.rowSchema ?? databaseSchema?.fields ?? {};
6344
+ return {
6345
+ content: [{
6346
+ type: 'text',
6347
+ text: JSON.stringify({
6348
+ scope: args.action_tag ? 'database_action' : 'database_primitive',
6349
+ product_tag: args.product_tag,
6350
+ env_slug: args.env_slug,
6351
+ database_tag: args.database_tag,
6352
+ table,
6353
+ operation: args.action_tag
6354
+ ? action?.operation ?? action?.type
6355
+ : args.database_operation ?? 'query',
6356
+ input_contract: args.action_tag ? action?.data ?? [] : generated?.payload?.input,
6357
+ output_contract: outputContract,
6358
+ row_schema: rowSchema,
6359
+ database_schema: databaseSchema,
6360
+ action: args.action_tag ? action : undefined,
6361
+ warnings: generated?.meta?.schema_warnings ?? [],
6362
+ discovery_only: true,
6363
+ executed_action: false,
6364
+ guidance: outputContract?.normalizedOutput
6365
+ ? 'Use output_contract for the envelope and row_schema for data item fields.'
6366
+ : 'Do not infer row fields or an action envelope when metadata is absent. Sync schema metadata and retry ductape_schema.',
6367
+ }, null, 2),
6368
+ }],
6369
+ };
6370
+ }
5996
6371
  const liveActionScope = [args.product_tag, args.app_tag, args.action_tag];
5997
6372
  if (liveActionScope.some(Boolean)) {
5998
6373
  if (!liveActionScope.every(Boolean)) {
@@ -6124,11 +6499,18 @@ async function main() {
6124
6499
  }, snippetGenerateHandler);
6125
6500
  server.registerTool('ductape_schema', {
6126
6501
  title: 'Ductape Asset Schema',
6127
- description: 'Returns a compact method index, one targeted asset schema, or one complete live app-action contract. ' +
6502
+ description: 'Returns a compact method index, one targeted asset schema, one complete live app-action contract, ' +
6503
+ 'or a live synchronized Database Action/direct database row contract. ' +
6128
6504
  'Call with module="app" or module="product" first to list method keys, then call again with ' +
6129
6505
  'module and method (for example method="databases.create") for the complete field schema. ' +
6130
6506
  'For one linked app action, call with product_tag, app_tag, and action_tag; do not fetch a full app catalogue. ' +
6507
+ 'For a saved Database Action, call with product_tag, env_slug, database_tag, and action_tag. ' +
6508
+ 'For a direct database call, call with product_tag, env_slug, database_tag, table, and database_operation. ' +
6509
+ 'Database results distinguish output_contract (the operation envelope) from row_schema (fields inside data rows). ' +
6510
+ 'This lookup is discovery-only and never executes the database action or primitive. ' +
6131
6511
  'Avoid calling without module unless you explicitly need the entire manifest.\n\n' +
6512
+ 'ALWAYS call the live database form before generating code that reads fields from a saved Database Action ' +
6513
+ 'or direct query result. If row_schema is unavailable, do not guess fields; sync the database schema and retry. ' +
6132
6514
  'ALWAYS call this before constructing a file for "resources <type> create" or any cloud ' +
6133
6515
  'import/provision operation — field shapes are not guessable from context.\n\n' +
6134
6516
  '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.31",
3
+ "version": "0.2.33",
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-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",
19
19
  "start": "node dist/index.js",
20
20
  "dev": "tsx src/index.ts"
21
21
  },