@instruktlabs/kiln 1.1.0 → 1.2.0

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 (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +12 -0
  4. package/dist/agent-run.mjs +220 -150
  5. package/dist/build.json +22 -22
  6. package/dist/cli.mjs +225 -150
  7. package/dist/mcp-engine.mjs +398 -144
  8. package/dist/mcp-server.mjs +1 -1
  9. package/docs/sdk.md +23 -0
  10. package/docs/tools-operations.md +1529 -0
  11. package/lib/discovery/index.d.ts +1 -1
  12. package/lib/discovery/index.js +2 -2
  13. package/lib/discovery/service.d.ts +3 -1
  14. package/lib/discovery/service.js +4 -2
  15. package/lib/engine-identity.d.ts +1 -1
  16. package/lib/engine-identity.js +1 -1
  17. package/lib/mcp-core.d.ts +2 -1
  18. package/lib/mcp-core.js +1 -0
  19. package/lib/mcp-engine.d.ts +2 -0
  20. package/lib/mcp-engine.js +8 -3
  21. package/lib/tools/discovery.d.ts +1 -1
  22. package/lib/tools/discovery.js +11 -9
  23. package/lib/tools/materials.d.ts +1 -1
  24. package/lib/tools/materials.js +44 -14
  25. package/lib/tools/operation-contract.d.ts +8 -0
  26. package/lib/tools/operation-contract.js +37 -0
  27. package/lib/tools/operations.d.ts +3 -0
  28. package/lib/tools/operations.js +154 -0
  29. package/lib/tools/registry.d.ts +6 -2
  30. package/lib/tools/registry.js +71 -23
  31. package/package.json +2 -1
  32. package/plugin.json +1 -1
  33. package/plugins/kiln-engine/.claude-plugin/plugin.json +1 -1
  34. package/plugins/kiln-engine/README.md +3 -3
  35. package/plugins/kiln-engine/package-provenance.json +6 -6
  36. package/plugins/kiln-engine/plugin.json +1 -1
  37. package/plugins/kiln-engine/runtime.json +1 -1
  38. package/src/discovery/index.ts +2 -0
  39. package/src/discovery/service.ts +3 -1
  40. package/src/engine-identity.ts +1 -1
  41. package/src/mcp-core.ts +3 -0
  42. package/src/mcp-engine.ts +11 -1
  43. package/src/tools/discovery.ts +20 -10
  44. package/src/tools/materials.ts +59 -15
  45. package/src/tools/operation-contract.ts +51 -0
  46. package/src/tools/operations.ts +177 -0
  47. package/src/tools/registry.ts +100 -20
@@ -0,0 +1,51 @@
1
+ import { z } from 'zod';
2
+ import type { ActionRequirements } from './actions';
3
+
4
+ /** Domain action metadata shared by grouped execution and named presentations. */
5
+ export interface KilnActionContract extends ActionRequirements {
6
+ readonly fields: readonly string[];
7
+ readonly description: string;
8
+ }
9
+
10
+ export function projectActionSchemas<A extends string>(
11
+ name: string,
12
+ input: z.ZodObject,
13
+ actions: Readonly<Record<A, KilnActionContract>>,
14
+ ): Record<A, z.ZodObject> {
15
+ if (input.def.checks?.length)
16
+ throw new Error(`${name}: cross-field constraints require an explicit operation schema`);
17
+ const json = z.toJSONSchema(input, { io: 'input' });
18
+ const selector = json.properties?.action as { enum?: unknown } | undefined;
19
+ const expected = Object.keys(actions).sort();
20
+ if (
21
+ !Array.isArray(selector?.enum) ||
22
+ JSON.stringify([...selector.enum].sort()) !== JSON.stringify(expected)
23
+ )
24
+ throw new Error(`${name}: unclassified actions`);
25
+ const classified = new Set([
26
+ 'action',
27
+ ...Object.values<KilnActionContract>(actions).flatMap((contract) => [...contract.fields]),
28
+ ]);
29
+ const unclassified = Object.keys(input.shape).filter((field) => !classified.has(field));
30
+ if (unclassified.length)
31
+ throw new Error(`${name}: Unclassified fields: ${unclassified.join(', ')}`);
32
+ const schemas: [string, z.ZodObject][] = [];
33
+ for (const [action, contract] of Object.entries<KilnActionContract>(actions)) {
34
+ for (const field of contract.required)
35
+ if (!contract.fields.includes(field))
36
+ throw new Error(`${name}.${action}: unmapped required ${field}`);
37
+ for (const field of json.required ?? [])
38
+ if (field !== 'action' && !contract.fields.includes(field))
39
+ throw new Error(`${name}.${action}: would omit required ${field}`);
40
+ const shape: Record<string, z.ZodType> = {};
41
+ for (const field of contract.fields) {
42
+ if (field === 'action' || !Object.hasOwn(input.shape, field))
43
+ throw new Error(`${name}.${action}: missing or reserved field ${field}`);
44
+ classified.add(field);
45
+ const source = input.shape[field]!;
46
+ shape[field] = contract.required.includes(field) ? source.nonoptional() : source;
47
+ }
48
+ schemas.push([action, z.strictObject(shape)]);
49
+ }
50
+ return Object.fromEntries(schemas) as Record<A, z.ZodObject>;
51
+ }
@@ -0,0 +1,177 @@
1
+ import { z } from 'zod';
2
+ import type { KilnToolContext, KilnToolDef } from './registry';
3
+ import { projectActionSchemas } from './operation-contract';
4
+
5
+ /** Adapt domain operations in the engine, never in a transport-specific skin. */
6
+ export function projectOperationDefs(
7
+ defs: readonly KilnToolDef[],
8
+ context: KilnToolContext,
9
+ ): KilnToolDef[] {
10
+ return defs.flatMap((def) => {
11
+ if (
12
+ !context.assetLibrary &&
13
+ ['kiln_save', 'kiln_assets', 'kiln_present', 'kiln_export', 'kiln_import'].includes(def.name)
14
+ )
15
+ return [];
16
+ if (def.name === 'kiln_renderer') return rendererOperations(def, context);
17
+ if (def.name === 'kiln_discover') return discoveryOperations(def);
18
+ if (def.name === 'kiln_material') return materialOperations(def);
19
+ if (def.name === 'kiln_assets') return assetOperations(def, context);
20
+ return [def];
21
+ });
22
+ }
23
+
24
+ function actionTools(def: KilnToolDef) {
25
+ if (!(def.inputSchema instanceof z.ZodObject) || !def.actionContract)
26
+ throw new Error(`${def.name}: missing shared action contract`);
27
+ const schemas = projectActionSchemas(def.name, def.inputSchema, def.actionContract);
28
+ return Object.fromEntries(
29
+ Object.entries(def.actionContract).map(([action, contract]) => [
30
+ action,
31
+ {
32
+ ...def,
33
+ actionContract: undefined,
34
+ name: `${def.name}_${action.replaceAll('-', '_')}`,
35
+ description: contract.description,
36
+ inputSchema: schemas[action]!,
37
+ run: async (raw: unknown) => def.run({ ...schemas[action]!.parse(raw), action }),
38
+ } satisfies KilnToolDef,
39
+ ]),
40
+ );
41
+ }
42
+
43
+ function assetOperations(def: KilnToolDef, context: KilnToolContext): KilnToolDef[] {
44
+ const tools = actionTools(def);
45
+ const list = tools.list!;
46
+ const listSchema = list.inputSchema as z.ZodObject;
47
+ // Remove only the legacy default: omission means all collections in this operation.
48
+ const collection = (listSchema.shape.collection as z.ZodDefault<z.ZodString>)
49
+ .unwrap()
50
+ .optional()
51
+ .describe(
52
+ 'Collection ID from kiln_assets_collections; omitted searches all configured collections.',
53
+ );
54
+ const inputSchema = listSchema.extend({ collection });
55
+ const search: KilnToolDef = {
56
+ ...list,
57
+ name: 'kiln_assets_search',
58
+ description:
59
+ 'Search saved assets with pagination. Omit collection for all configured collections, or select one explicitly. Results identify exact collection, asset and revision; partial read failures are reported.',
60
+ inputSchema,
61
+ run: async (raw) => {
62
+ const input = inputSchema.parse(raw);
63
+ const { collection: _collection, ...filters } = input;
64
+ if (input.collection === undefined) return tools.catalog!.run(filters);
65
+ if (!context.assetLibrary?.collections().some((item) => item.id === input.collection))
66
+ throw new Error(
67
+ 'Unknown collection. Use kiln_assets_collections to list available destinations.',
68
+ );
69
+ const result = (await list.run(input)) as {
70
+ total: number;
71
+ nextOffset: number | null;
72
+ assets: Record<string, unknown>[];
73
+ };
74
+ return {
75
+ errors: [],
76
+ total: result.total,
77
+ nextOffset: result.nextOffset,
78
+ assets: result.assets.map((asset) => ({ ...asset, collection: input.collection })),
79
+ };
80
+ },
81
+ };
82
+ return [tools.collections!, search, tools.get!, tools.restore!];
83
+ }
84
+
85
+ function materialOperations(def: KilnToolDef): KilnToolDef[] {
86
+ const tools = actionTools(def);
87
+ const presets = tools.presets!;
88
+ const inputSchema = (presets.inputSchema as z.ZodObject).extend({
89
+ scope: z.enum(['all', 'presets', 'saved']).default('all'),
90
+ });
91
+ const search: KilnToolDef = {
92
+ ...presets,
93
+ name: 'kiln_material_search',
94
+ description:
95
+ 'Browse shipped material recipes and saved material revisions, optionally filtered by exact tag. Recipe IDs and saved material IDs are distinct. Use scope to select presets, saved or both.',
96
+ inputSchema,
97
+ run: async (raw) => {
98
+ const { scope, ...input } = inputSchema.parse(raw);
99
+ const recipes =
100
+ scope === 'saved'
101
+ ? { presets: [] }
102
+ : ((await presets.run(input)) as { presets: Record<string, unknown>[] });
103
+ const saved =
104
+ scope === 'presets'
105
+ ? { materials: [] }
106
+ : ((await tools.list!.run(input)) as { materials: Record<string, unknown>[] });
107
+ return {
108
+ ok: true,
109
+ presets: recipes.presets.map((item) => ({ ...item, kind: 'preset' })),
110
+ materials: saved.materials.map((item) => ({ ...item, kind: 'saved' })),
111
+ };
112
+ },
113
+ };
114
+ const preset = tools['create-preset']!;
115
+ const procedural = tools['create-procedural']!;
116
+ const createInput = z.strictObject({
117
+ definition: z.discriminatedUnion('kind', [
118
+ (preset.inputSchema as z.ZodObject).extend({ kind: z.literal('preset') }),
119
+ (procedural.inputSchema as z.ZodObject).extend({ kind: z.literal('procedural') }),
120
+ ]),
121
+ });
122
+ const create: KilnToolDef = {
123
+ ...preset,
124
+ name: 'kiln_material_create',
125
+ description:
126
+ 'Create and retain one immutable material from a preset or procedural definition. Presets require explicit seed, creator and license. Pin the returned materialId/revisionId for rendering. Does not fetch URLs or execute source.',
127
+ inputSchema: createInput,
128
+ run: async (raw) => {
129
+ const { kind, ...input } = createInput.parse(raw).definition;
130
+ return (kind === 'preset' ? preset : procedural).run(input);
131
+ },
132
+ };
133
+ return [search, tools.get!, create, tools.import!];
134
+ }
135
+
136
+ function rendererOperations(def: KilnToolDef, context: KilnToolContext): KilnToolDef[] {
137
+ if (!context.renderCapabilities) return [];
138
+ return ['status', ...(context.reprobeRenderer ? ['reprobe'] : [])].map((action) => {
139
+ const inputSchema = z.strictObject({});
140
+ return {
141
+ ...def,
142
+ name: `kiln_renderer_${action}`,
143
+ inputSchema,
144
+ run: async (raw: unknown) => {
145
+ inputSchema.parse(raw);
146
+ return def.run({ action });
147
+ },
148
+ };
149
+ });
150
+ }
151
+
152
+ function discoveryOperations(def: KilnToolDef): KilnToolDef[] {
153
+ const original = def.inputSchema as z.ZodObject;
154
+ const { capabilities: _capabilities, ...fields } = original.shape;
155
+ const inputSchema = z.strictObject(fields);
156
+ const empty = z.strictObject({});
157
+ return [
158
+ {
159
+ ...def,
160
+ description:
161
+ 'Discover geometry helpers, assemblies, recipes and complete input shapes. Search with modeling language or fetch up to six exact IDs. Use kiln_capabilities for current host facts.',
162
+ inputSchema,
163
+ run: async (raw) => def.run(inputSchema.parse(raw)),
164
+ },
165
+ {
166
+ ...def,
167
+ name: 'kiln_capabilities',
168
+ description:
169
+ 'Inspect declared host capabilities, execution limits and available storage. Observed rendering fidelity is returned by each render.',
170
+ inputSchema: empty,
171
+ run: async (raw) => {
172
+ empty.parse(raw);
173
+ return def.run({ capabilities: true });
174
+ },
175
+ },
176
+ ];
177
+ }
@@ -24,6 +24,8 @@ import { createKilnMaterialDef } from './materials';
24
24
  import { createKilnReviewDef, type ReviewStore } from './review';
25
25
  import { withWorkspaceContext } from './workspace';
26
26
  import { describeInputError } from './actions';
27
+ import { projectOperationDefs } from './operations';
28
+ import type { KilnActionContract } from './operation-contract';
27
29
  import {
28
30
  advancedCaptureInput,
29
31
  cameraShotInput,
@@ -94,6 +96,8 @@ import type { TextureUsage } from '../textures';
94
96
 
95
97
  export const KILN_ASSET_WIDGET_URI = 'ui://kiln/asset-v5.html';
96
98
  export interface KilnToolDef {
99
+ /** Shared action ownership and requirements; never hand-maintained by transports. */
100
+ actionContract?: Readonly<Record<string, import('./operation-contract').KilnActionContract>>;
97
101
  outputSchema?: z.ZodType;
98
102
  /** Optional MCP App presentation; metadata is hidden from the language model. */
99
103
  ui?: {
@@ -2498,13 +2502,14 @@ async function readSavedRevision(
2498
2502
  collection: string,
2499
2503
  assetId: string,
2500
2504
  revisionId: string,
2505
+ listCall = `kiln_assets { action: 'list', collection: '${collection}' }`,
2501
2506
  ) {
2502
2507
  try {
2503
2508
  return await target.read(collection, assetId, revisionId);
2504
2509
  } catch (error) {
2505
2510
  if ((error as NodeJS.ErrnoException).code === 'ENOENT')
2506
2511
  throw new Error(
2507
- `Unknown asset ${assetId} revision ${revisionId} in collection ${collection}. kiln_assets { action: 'list', collection: '${collection}' } lists the saved revisions.`,
2512
+ `Unknown asset ${assetId} revision ${revisionId} in collection ${collection}. ${listCall} lists the saved revisions.`,
2508
2513
  );
2509
2514
  throw error;
2510
2515
  }
@@ -2512,12 +2517,19 @@ async function readSavedRevision(
2512
2517
 
2513
2518
  export function createKilnProgramToolRegistry(
2514
2519
  suppliedContext: KilnToolContext = {},
2520
+ ): KilnToolDef[] {
2521
+ return createProgramDefs(suppliedContext, 'grouped');
2522
+ }
2523
+
2524
+ function createProgramDefs(
2525
+ suppliedContext: KilnToolContext,
2526
+ presentation: 'grouped' | 'operations',
2515
2527
  ): KilnToolDef[] {
2516
2528
  toolRequirements(suppliedContext);
2517
2529
  const store = suppliedContext.programStore ?? new MemoryProgramStore();
2518
2530
  const context = withCaptureCache(withBuildCache({ ...suppliedContext, programStore: store }));
2519
2531
  return [
2520
- createKilnDiscoveryDef({ ...suppliedContext, programStore: store }),
2532
+ createKilnDiscoveryDef({ ...suppliedContext, programStore: store }, presentation),
2521
2533
  {
2522
2534
  name: 'kiln_renderer',
2523
2535
  description:
@@ -2564,7 +2576,9 @@ export function createKilnProgramToolRegistry(
2564
2576
  ...(context.projectStore
2565
2577
  ? [createKilnProjectDef(context.projectStore, context.projectBundleReader)]
2566
2578
  : []),
2567
- ...(context.materialLibrary ? [createKilnMaterialDef(context.materialLibrary)] : []),
2579
+ ...(context.materialLibrary
2580
+ ? [createKilnMaterialDef(context.materialLibrary, presentation)]
2581
+ : []),
2568
2582
  ...(context.reviewStore && context.assetLibrary
2569
2583
  ? [
2570
2584
  createKilnReviewDef({
@@ -2574,7 +2588,7 @@ export function createKilnProgramToolRegistry(
2574
2588
  }),
2575
2589
  ]
2576
2590
  : []),
2577
- ...createKilnAssetDefs({ ...context, programStore: store }),
2591
+ ...createKilnAssetDefs({ ...context, programStore: store }, presentation),
2578
2592
  ]
2579
2593
  .map((def) => withWorkspaceContext(def, context))
2580
2594
  .map((def) => ({
@@ -2591,6 +2605,11 @@ export function createKilnProgramToolRegistry(
2591
2605
  }));
2592
2606
  }
2593
2607
 
2608
+ /** Opt-in named domain operations; grouped local/native contracts remain unchanged. */
2609
+ export function createKilnOperationToolRegistry(context: KilnToolContext = {}): KilnToolDef[] {
2610
+ return projectOperationDefs(createProgramDefs(context, 'operations'), context);
2611
+ }
2612
+
2594
2613
  /** Canonical native workflow: shared definitions plus one host-owned terminal. */
2595
2614
  export function createKilnNativeToolRegistry(
2596
2615
  suppliedContext: KilnToolContext,
@@ -2686,6 +2705,36 @@ const assetSelector = {
2686
2705
  revisionId: z.string().regex(/^[a-z][a-z0-9_-]{0,79}$/),
2687
2706
  };
2688
2707
 
2708
+ const ASSET_CONTRACT = {
2709
+ collections: {
2710
+ fields: [],
2711
+ required: [],
2712
+ description: 'List configured asset storage collections. Collection is not project membership.',
2713
+ },
2714
+ catalog: {
2715
+ fields: ['assetId', 'query', 'offset', 'limit'],
2716
+ required: [],
2717
+ description: 'Search saved assets across configured collections with pagination.',
2718
+ },
2719
+ list: {
2720
+ fields: ['collection', 'assetId', 'query', 'offset', 'limit'],
2721
+ required: [],
2722
+ description: 'Search saved assets in one collection with pagination.',
2723
+ },
2724
+ get: {
2725
+ fields: ['collection', 'assetId', 'revisionId'],
2726
+ required: ['assetId'],
2727
+ description:
2728
+ 'Read a saved revision and its download descriptors. Omit revisionId for the newest revision.',
2729
+ },
2730
+ restore: {
2731
+ fields: ['collection', 'assetId', 'revisionId'],
2732
+ required: ['assetId'],
2733
+ description:
2734
+ 'Restore exact saved source as programRef for editing or rendering. Omit revisionId for the newest revision. Does not overwrite the saved revision. Binary-only assets have no source.',
2735
+ },
2736
+ } satisfies Record<string, KilnActionContract>;
2737
+
2689
2738
  /**
2690
2739
  * The revision a get or restore without `revisionId` means: the newest, the
2691
2740
  * one no later revision names as its parent, ties to the latest `createdAt`
@@ -2701,12 +2750,11 @@ export async function newestRevision(
2701
2750
  },
2702
2751
  collection: string,
2703
2752
  assetId: string,
2753
+ listCall = `kiln_assets { action: 'list', collection: '${collection}' }`,
2704
2754
  ): Promise<string> {
2705
2755
  const revisions = (await target.list(collection)).filter((a) => a.assetId === assetId);
2706
2756
  if (!revisions.length)
2707
- throw new Error(
2708
- `Unknown asset ${assetId} in ${collection}; kiln_assets { action: 'list', collection: '${collection}' } lists them.`,
2709
- );
2757
+ throw new Error(`Unknown asset ${assetId} in ${collection}; ${listCall} lists them.`);
2710
2758
  const parents = new Set(revisions.map((a) => a.parentRevision).filter(Boolean));
2711
2759
  const heads = revisions.filter((a) => !parents.has(a.revisionId));
2712
2760
  const newest = (heads.length ? heads : revisions).sort((a, b) =>
@@ -2797,7 +2845,23 @@ export async function buildProgramAssetDraft(
2797
2845
  }
2798
2846
 
2799
2847
  /** All asset operation schemas live here, alongside the existing tool definitions. */
2800
- export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
2848
+ export function createKilnAssetDefs(
2849
+ context: KilnToolContext,
2850
+ presentation: 'grouped' | 'operations' = 'grouped',
2851
+ ): KilnToolDef[] {
2852
+ const listCall = (collection: string) =>
2853
+ presentation === 'operations'
2854
+ ? `kiln_assets_search({ collection: '${collection}' })`
2855
+ : `kiln_assets { action: 'list', collection: '${collection}' }`;
2856
+ const selector =
2857
+ presentation === 'operations'
2858
+ ? {
2859
+ ...assetSelector,
2860
+ collection: assetSelector.collection.describe(
2861
+ 'Collection ID from kiln_assets_collections; default project.',
2862
+ ),
2863
+ }
2864
+ : assetSelector;
2801
2865
  const library = () => {
2802
2866
  if (!context.assetLibrary)
2803
2867
  throw new Error(
@@ -2828,11 +2892,11 @@ export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
2828
2892
  downloadUrls: await context.assetDownloadUrls?.(collection, asset.assetId, asset.revisionId),
2829
2893
  });
2830
2894
  const saveInput = z.object({
2831
- collection: assetSelector.collection,
2895
+ collection: selector.collection,
2832
2896
  programRef: z.string(),
2833
2897
  name: z.string().min(1).max(200),
2834
- assetId: assetSelector.assetId.optional(),
2835
- parentRevision: assetSelector.revisionId.optional(),
2898
+ assetId: selector.assetId.optional(),
2899
+ parentRevision: selector.revisionId.optional(),
2836
2900
  tags: z.array(z.string().max(80)).max(30).optional(),
2837
2901
  brief: z.string().max(8000).optional(),
2838
2902
  description: z.string().max(4000).optional(),
@@ -2850,16 +2914,16 @@ export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
2850
2914
  });
2851
2915
  const assetsInput = z.object({
2852
2916
  action: z.enum(['collections', 'catalog', 'list', 'get', 'restore']).default('list'),
2853
- collection: assetSelector.collection,
2854
- assetId: assetSelector.assetId.optional(),
2855
- revisionId: assetSelector.revisionId
2917
+ collection: selector.collection,
2918
+ assetId: selector.assetId.optional(),
2919
+ revisionId: selector.revisionId
2856
2920
  .optional()
2857
2921
  .describe('get, restore: the saved revision; omitted, the newest.'),
2858
2922
  query: z.string().max(200).optional(),
2859
2923
  offset: z.number().int().min(0).default(0),
2860
2924
  limit: z.number().int().min(1).max(50).default(20),
2861
2925
  });
2862
- const exportInput = z.object(assetSelector);
2926
+ const exportInput = z.object(selector);
2863
2927
  const profileExportInput = exportInput.extend({
2864
2928
  profile: z
2865
2929
  .enum(['editable', 'runtime'])
@@ -2869,8 +2933,8 @@ export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
2869
2933
  ),
2870
2934
  });
2871
2935
  const importInput = z.object({
2872
- ...assetSelector,
2873
- sourceCollection: assetSelector.collection,
2936
+ ...selector,
2937
+ sourceCollection: selector.collection,
2874
2938
  });
2875
2939
  return [
2876
2940
  {
@@ -2892,6 +2956,7 @@ export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
2892
2956
  input.collection,
2893
2957
  input.assetId,
2894
2958
  input.parentRevision,
2959
+ listCall(input.collection),
2895
2960
  );
2896
2961
  assertSavedRequirementsAuthorized(previous.manifest, activeRequirements);
2897
2962
  }
@@ -2908,6 +2973,7 @@ export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
2908
2973
  description:
2909
2974
  'Browse saved assets: collections discovers storage; catalog searches all configured collections; list searches one. Both searches paginate. get returns a build record/downloads; restore loads exact source for kiln_source/kiln_edit. Collection is not project membership. Binary-only assets cannot restore source.',
2910
2975
  inputSchema: assetsInput,
2976
+ actionContract: ASSET_CONTRACT,
2911
2977
  run: async (raw) => {
2912
2978
  const input = assetsInput.parse(raw);
2913
2979
  const activeRequirements = toolRequirements(context);
@@ -2961,13 +3027,25 @@ export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
2961
3027
  })),
2962
3028
  };
2963
3029
  }
2964
- if (!input.assetId)
3030
+ if (ASSET_CONTRACT[input.action].required.some((field) => !input[field as 'assetId']))
2965
3031
  throw new Error(
2966
3032
  `kiln_assets ${input.action} requires assetId; kiln_assets { action: 'list', collection: '${input.collection}' } lists them.`,
2967
3033
  );
2968
3034
  const revisionId =
2969
- input.revisionId ?? (await newestRevision(target, input.collection, input.assetId));
2970
- const record = await readSavedRevision(target, input.collection, input.assetId, revisionId);
3035
+ input.revisionId ??
3036
+ (await newestRevision(
3037
+ target,
3038
+ input.collection,
3039
+ input.assetId!,
3040
+ listCall(input.collection),
3041
+ ));
3042
+ const record = await readSavedRevision(
3043
+ target,
3044
+ input.collection,
3045
+ input.assetId!,
3046
+ revisionId,
3047
+ listCall(input.collection),
3048
+ );
2971
3049
  if (input.action === 'get') return links(input.collection, record.manifest);
2972
3050
  const saved = assertSavedRequirementsAuthorized(record.manifest, activeRequirements);
2973
3051
  const code = record.files['source.kiln.js'];
@@ -3059,6 +3137,7 @@ export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
3059
3137
  input.collection,
3060
3138
  input.assetId,
3061
3139
  input.revisionId,
3140
+ listCall(input.collection),
3062
3141
  );
3063
3142
  const { inspectDrawDiagnostics } = await import('../draw-diagnostics');
3064
3143
  if (input.profile === 'runtime') {
@@ -3102,6 +3181,7 @@ export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
3102
3181
  input.sourceCollection,
3103
3182
  input.assetId,
3104
3183
  input.revisionId,
3184
+ listCall(input.sourceCollection),
3105
3185
  );
3106
3186
  await target.import(input.collection, [record]);
3107
3187
  return links(input.collection, record.manifest);