@instruktlabs/kiln 1.0.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 (92) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +60 -3
  5. package/CONTRIBUTING.md +16 -7
  6. package/README.md +95 -381
  7. package/dist/agent-run.mjs +220 -150
  8. package/dist/build.json +40 -28
  9. package/dist/cli.mjs +242 -163
  10. package/dist/mcp-engine.mjs +412 -154
  11. package/dist/mcp-server.mjs +1 -1
  12. package/docs/architecture.md +6 -3
  13. package/docs/chatgpt.md +20 -5
  14. package/docs/examples.md +40 -170
  15. package/docs/install.md +100 -35
  16. package/docs/migration.md +20 -0
  17. package/docs/sdk.md +49 -5
  18. package/docs/tools-operations.md +1529 -0
  19. package/lib/asset-widget-uri.d.ts +9 -0
  20. package/lib/asset-widget-uri.js +9 -0
  21. package/lib/cli-render-mode.js +10 -3
  22. package/lib/discovery/index.d.ts +1 -1
  23. package/lib/discovery/index.js +2 -2
  24. package/lib/discovery/portable.d.ts +9 -0
  25. package/lib/discovery/portable.js +6 -0
  26. package/lib/discovery/service.d.ts +3 -1
  27. package/lib/discovery/service.js +4 -2
  28. package/lib/engine-identity.d.ts +1 -1
  29. package/lib/engine-identity.js +1 -1
  30. package/lib/evaluator/isolation.js +19 -5
  31. package/lib/evaluator/probe-invariants.d.ts +11 -0
  32. package/lib/evaluator/probe-invariants.js +36 -0
  33. package/lib/evaluator/probe-worker.js +16 -4
  34. package/lib/loopback.d.ts +6 -0
  35. package/lib/loopback.js +6 -0
  36. package/lib/mcp-core.d.ts +210 -0
  37. package/lib/mcp-core.js +279 -0
  38. package/lib/mcp-engine.d.ts +59 -0
  39. package/lib/mcp-engine.js +289 -0
  40. package/lib/mcp-manifest.d.ts +15 -0
  41. package/lib/mcp-manifest.js +65 -0
  42. package/lib/mcp.d.ts +5 -0
  43. package/lib/mcp.js +2 -0
  44. package/lib/render-service-host.js +1 -1
  45. package/lib/tools/discovery.d.ts +1 -1
  46. package/lib/tools/discovery.js +11 -9
  47. package/lib/tools/materials.d.ts +1 -1
  48. package/lib/tools/materials.js +44 -14
  49. package/lib/tools/operation-contract.d.ts +8 -0
  50. package/lib/tools/operation-contract.js +37 -0
  51. package/lib/tools/operations.d.ts +3 -0
  52. package/lib/tools/operations.js +154 -0
  53. package/lib/tools/registry.d.ts +6 -2
  54. package/lib/tools/registry.js +71 -23
  55. package/package.json +23 -1
  56. package/plugin.json +1 -1
  57. package/plugins/kiln-engine/.claude-plugin/icon.png +0 -0
  58. package/plugins/kiln-engine/.claude-plugin/plugin.json +9 -3
  59. package/plugins/kiln-engine/PRIVACY.md +100 -0
  60. package/plugins/kiln-engine/README.md +103 -18
  61. package/plugins/kiln-engine/{scripts/setup-workspace.mjs → bin/kiln-setup-workspace.mjs} +37 -9
  62. package/plugins/kiln-engine/package-provenance.json +15 -8
  63. package/plugins/kiln-engine/plugin.json +10 -4
  64. package/plugins/kiln-engine/runtime.json +10 -1
  65. package/plugins/kiln-engine/skills/kiln-setup-workspace/SKILL.md +17 -18
  66. package/plugins/kiln-engine/skills/kiln-setup-workspace/references/plugin-install.md +28 -8
  67. package/plugins/kiln-engine/skills/kiln-setup-workspace/references/renderer-setup.md +11 -0
  68. package/scripts/create-workspace.mjs +71 -13
  69. package/scripts/workspace-config.mjs +239 -0
  70. package/scripts/workspace-project.mjs +384 -0
  71. package/scripts/workspace-transaction.mjs +504 -0
  72. package/skills/kiln-setup-workspace/SKILL.md +17 -18
  73. package/skills/kiln-setup-workspace/references/plugin-install.md +28 -8
  74. package/skills/kiln-setup-workspace/references/renderer-setup.md +11 -0
  75. package/src/cli-render-mode.ts +9 -9
  76. package/src/cli.ts +3 -3
  77. package/src/discovery/index.ts +2 -0
  78. package/src/discovery/portable.ts +9 -0
  79. package/src/discovery/service.ts +3 -1
  80. package/src/engine-identity.ts +1 -1
  81. package/src/evaluator/isolation.ts +27 -6
  82. package/src/evaluator/probe-invariants.ts +50 -0
  83. package/src/evaluator/probe-worker.ts +22 -4
  84. package/src/mcp-core.ts +3 -0
  85. package/src/mcp-engine.ts +11 -1
  86. package/src/mcp.ts +5 -0
  87. package/src/render-service-host.ts +1 -1
  88. package/src/tools/discovery.ts +20 -10
  89. package/src/tools/materials.ts +59 -15
  90. package/src/tools/operation-contract.ts +51 -0
  91. package/src/tools/operations.ts +177 -0
  92. package/src/tools/registry.ts +100 -20
@@ -0,0 +1,11 @@
1
+ # Local renderer setup
2
+
3
+ Setup asks for render mode `auto`: textured or metallic scenes use a compatible GPU service, starting one lazily when available. Ordinary untextured scenes with zero metalness and no advanced material extensions use CPU views, even with a GPU available. CPU views support silhouette, proportion and contact review, but cannot confirm PBR appearance. Use CLI `--render gpu` when material review is required, including roughness on nonmetallic surfaces.
4
+
5
+ The renderer code is included with Kiln; its native GPU dependency is optional at the Kiln package root. For material-dependent tasks, check `node kiln.mjs service status` before starting the authoring session. If dependencies are missing or incompatible, reinstall the official Kiln package with optional dependencies enabled. npm 11 may print an `install-scripts` warning for the optional `webgpu` package; its postinstall only clears the macOS download quarantine from the prebuilt Dawn binary the package ships, so Windows and Linux need nothing and the warning is not an install failure. Read `runtime` from `.kiln/workspace.json` to identify the installation. A source checkout uses `bun install --frozen-lockfile` at that root. Setup checks dependency resolution without loading native code; ready dependencies alone do not prove the device can render.
6
+
7
+ Once dependencies are available, local CLI/MCP auto mode starts a managed service lazily for a view that needs PBR shading. Compatible clients share it; it survives the initiating session and exits after five minutes without admitted uploads, queued jobs or active renders. From the installation root, `node --import ./render-service/src/register-hooks.mjs render-service/src/server.mjs` starts a manual service that remains running while idle. It binds loopback; widening `HOST` requires `RENDER_SERVICE_TOKEN`.
8
+
9
+ `node kiln.mjs service status` reports installation, verified health and lifetime. `node kiln.mjs service start` joins a compatible service or starts a managed one the way an on-demand view does, and prints its port, renderer, pid, lifetime and build; it starts nothing when a stale, foreign or unknown listener holds the socket. After dependency repair or a cached startup failure, call `kiln_renderer({action:"reprobe"})` inside the MCP session to refresh its existing route. It neither installs dependencies nor starts a renderer; the next view starts one if needed. CLI `service reprobe` checks only its own process. Restart the session after changing Kiln, environment variables or credentials. Unknown or incompatible listeners are reported and left alone. `service stop` explicitly stops a verified local service. If no usable GPU is available, report the material-review limitation and continue on CPU views when that fits the task.
10
+
11
+ A `KILN_RENDER_PORT_URL` whose origin is exactly the shared local socket (`http://127.0.0.1:8000` by default) takes the local route: it starts on demand and restarts after an idle exit. For rendering on another device, configure the MCP server process's `KILN_RENDER_PORT_URL` and, when needed, `KILN_RENDER_TOKEN` through the chosen harness. CLI accepts `--render-port URL` and reads `KILN_RENDER_TOKEN`. Start a compatible Kiln renderer on that device; its wider bind requires `RENDER_SERVICE_TOKEN` unless an authenticated proxy is explicitly configured. Manage the remote service on its own device. Verify the selected route with capabilities and require an actual `viewFidelity` receipt for material evidence.
@@ -71,16 +71,16 @@ export function makeLazyRenderPort(
71
71
  return await port(req, execution);
72
72
  } catch (error) {
73
73
  if (execution?.signal?.aborted || (error as Error).name === 'TimeoutError') throw error;
74
- if (
75
- (
76
- await readRenderServiceHealth(url, {
77
- token,
78
- signal: execution?.signal,
79
- })
80
- ).kind !== 'absent'
81
- )
82
- throw error;
74
+ // A different capture may already have replaced this failed connection.
75
+ // Its healthy socket must not make us rethrow a late error from the old one.
76
+ if (resolving !== pending) return (await resolve()).port(req, execution);
77
+ const health = await readRenderServiceHealth(url, {
78
+ token,
79
+ signal: execution?.signal,
80
+ });
83
81
  execution?.signal?.throwIfAborted();
82
+ if (resolving !== pending) return (await resolve()).port(req, execution);
83
+ if (health.kind !== 'absent') throw error;
84
84
  // Concurrent captures can all observe the old socket disappearing. Only
85
85
  // the first retires its connection; the others share the replacement.
86
86
  if (resolving === pending) resolving = undefined;
package/src/cli.ts CHANGED
@@ -82,11 +82,11 @@ EXPORT ENVIRONMENT
82
82
  KILN_INDEX_POLICY indexed (default) | asBuilt (requires optimization off)
83
83
 
84
84
  EXAMPLES
85
- kiln render examples/crate.kiln.js --out crate.glb --views sheet.png
85
+ kiln render my-asset.kiln.js --out asset.glb --views sheet.png
86
86
  kiln generate "a weathered wooden crate" --out crate.glb --views sheet.png
87
- kiln render examples/crate.kiln.js --render cpu --views sheet.png
87
+ kiln render my-asset.kiln.js --render cpu --views sheet.png
88
88
  kiln render p_RETURNED_HANDLE --capture cameras.json --views chosen.png
89
- kiln render examples/crate.kiln.js --views sheet.png --backdrop light
89
+ kiln render my-asset.kiln.js --views sheet.png --backdrop light
90
90
  `;
91
91
 
92
92
  interface Args extends WorkspaceSelection {
@@ -21,6 +21,7 @@ export function createDiscovery(
21
21
  discovery: { mode: 'lexical', offline: true, requiresModel: false },
22
22
  }),
23
23
  notes: () => Promise<string[]> = async () => [],
24
+ capabilitiesCall = 'capabilities:true',
24
25
  ) {
25
26
  snapshot ??= (() => {
26
27
  const entries = listDiscoveryEntries();
@@ -32,5 +33,6 @@ export function createDiscovery(
32
33
  capabilities,
33
34
  REMOVED_AUTHORING_HELPERS,
34
35
  notes,
36
+ capabilitiesCall,
35
37
  );
36
38
  }
@@ -0,0 +1,9 @@
1
+ /** Search a host-supplied catalog without importing Node's catalog construction. */
2
+ export { createDiscoveryService } from './service';
3
+ export type { DiscoveryIndex, DiscoveryResponse, DiscoverySearchEvidence } from './service';
4
+ export { createLexicalDiscoveryIndex, DISCOVERY_INDEX_VERSION } from './lexical-index';
5
+ export { discoveryEntrySchema, parseCatalog } from './catalog-schema';
6
+ export type { DiscoveryEntry, OperationContract } from './catalog-schema';
7
+ export { discoveryInputSchema, parseDiscoveryRequest } from './query-schema';
8
+ export type { DiscoveryRequest } from './query-schema';
9
+ export { REMOVED_AUTHORING_HELPERS } from '../geometry-catalog';
@@ -63,6 +63,8 @@ export function createDiscoveryService(
63
63
  retirements: Readonly<Record<string, string>> = {},
64
64
  /** Host notes for the overview, such as the project an omitted `projectId` selects. */
65
65
  notes: () => Promise<string[]> = async () => [],
66
+ /** Host-selected capability entrypoint, never a model-provided instruction. */
67
+ capabilitiesCall = 'capabilities:true',
66
68
  ): (input: unknown) => Promise<DiscoveryResponse> {
67
69
  const entries = parseCatalog(source);
68
70
  const byId = new Map(entries.map((entry) => [entry.id, entry]));
@@ -94,7 +96,7 @@ export function createDiscoveryService(
94
96
  'Parts auto-add to opts.parent; do not wrap createPart in parent.add. Part rotation uses degrees.',
95
97
  'Custom THREE.BufferGeometry and ordinary JavaScript functions remain available. Recipes offer guidance and never restrict modeling.',
96
98
  'Send source once and reuse programRef for reads, edits, inspection and rendering. Review geometry, materials and destination constraints separately.',
97
- 'Search for the operation you need in ordinary language, then fetch exact contracts with ids. Use capabilities:true for the current host.',
99
+ `Search for the operation you need in ordinary language, then fetch exact contracts with ids. Use ${capabilitiesCall} for the current host.`,
98
100
  ],
99
101
  };
100
102
  const error = (
@@ -27,7 +27,7 @@
27
27
  * `src/__tests__/package-contents.test.ts` fails if it disagrees with the manifest
28
28
  * or with any plugin manifest, which is what keeps the literal honest.
29
29
  */
30
- export const ENGINE_VERSION = '1.0.0';
30
+ export const ENGINE_VERSION = '1.2.0';
31
31
 
32
32
  /**
33
33
  * Where this engine was loaded from, as a `file://` URL of the package root.
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readlinkSync } from 'node:fs';
3
3
  import { posix } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { isolatedWorkerUrls } from './worker-urls';
@@ -17,6 +17,7 @@ const DEFAULT_BWRAP_PATH = '/usr/local/bin/bwrap';
17
17
  const DEFAULT_SETPRIV_PATH = '/usr/bin/setpriv';
18
18
  const DEFAULT_PRLIMIT_PATH = '/usr/bin/prlimit';
19
19
  const DEFAULT_NODE_PATH = '/usr/local/bin/node';
20
+ const ENV_PATH = '/usr/bin/env';
20
21
  const READINESS_VERSION = 'kiln.evaluator.isolation-readiness.v1';
21
22
  const TRANSPORT_VERSION = 'kiln.evaluator.isolation-transport.v1';
22
23
  const TRANSPORT_STDOUT_MARKER = 'kiln-evaluator-transport-boot-v1\n';
@@ -159,7 +160,7 @@ function isolatedEvaluatorLaunchWithLoader(
159
160
  const setprivPath = requiredAbsolutePath(host.setprivPath ?? DEFAULT_SETPRIV_PATH);
160
161
  const prlimitPath = requiredAbsolutePath(host.prlimitPath ?? DEFAULT_PRLIMIT_PATH);
161
162
  const nodePath = requiredAbsolutePath(host.nodePath ?? DEFAULT_NODE_PATH);
162
- for (const executable of [bwrapPath, setprivPath, prlimitPath, nodePath]) {
163
+ for (const executable of [bwrapPath, setprivPath, prlimitPath, nodePath, ENV_PATH]) {
163
164
  if (!pathExists(executable)) isolationUnavailable();
164
165
  }
165
166
  const resolvedWorkerPath = workerInsideRuntime(workerPath, runtimeRoot);
@@ -195,14 +196,16 @@ function isolatedEvaluatorLaunchWithLoader(
195
196
  for (const mount of runtimeMounts(runtimeRoot, pathExists)) {
196
197
  bwrapArgs.push('--ro-bind', mount, mount);
197
198
  }
198
- const nodeArgs = [nodePath, '--max-old-space-size=512', '--disable-proto=throw'];
199
+ // Remove the legacy mutation accessor. Throw mode breaks the Khronos
200
+ // validator's feature detection before any GLB can be validated.
201
+ const nodeArgs = [nodePath, '--max-old-space-size=512', '--disable-proto=delete'];
199
202
  if (loader === 'tsx') nodeArgs.push('--import', 'tsx');
200
203
  nodeArgs.push(resolvedWorkerPath);
201
204
  bwrapArgs.push(
202
205
  '--chdir',
203
206
  runtimeRoot,
204
- '--preserve-fds',
205
- '1',
207
+ // Bubblewrap passes inherited fds to the command. The spawn caller supplies
208
+ // only stdio and the fd3 protocol pipe; no descriptor-preservation flag exists.
206
209
  '--',
207
210
  prlimitPath,
208
211
  '--cpu=65:65',
@@ -211,6 +214,12 @@ function isolatedEvaluatorLaunchWithLoader(
211
214
  '--nofile=64:64',
212
215
  '--nproc=64:64',
213
216
  '--',
217
+ // Bubblewrap adds PWD after --clearenv. Clear the final worker environment
218
+ // again instead of weakening the exact two-variable readiness invariant.
219
+ ENV_PATH,
220
+ '-i',
221
+ 'NODE_ENV=production',
222
+ 'NO_COLOR=1',
214
223
  ...nodeArgs,
215
224
  );
216
225
 
@@ -435,7 +444,11 @@ async function runIsolationProbe(
435
444
  export async function assertIsolatedEvaluatorReady(
436
445
  host: IsolatedEvaluatorHost = {},
437
446
  ): Promise<EvaluatorIsolationReadiness> {
438
- if (typeof process.getuid !== 'function' || process.getuid() === 0) {
447
+ if (
448
+ typeof process.getuid !== 'function' ||
449
+ process.getuid() === 0 ||
450
+ process.geteuid?.() !== process.getuid()
451
+ ) {
439
452
  throw new EvaluatorIsolationReadinessError('invariant-namespace');
440
453
  }
441
454
  const workers = isolatedWorkerUrls(import.meta.url);
@@ -446,6 +459,14 @@ export async function assertIsolatedEvaluatorReady(
446
459
  try {
447
460
  transportLaunch = isolatedEvaluatorLaunchWithLoader(transportPath, host, 'module');
448
461
  probeLaunch = isolatedEvaluatorLaunch(probePath, host);
462
+ // Trusted parent evidence is passed as bounded probe arguments, never
463
+ // through the source worker's environment or generated-source protocol.
464
+ probeLaunch.args.push(
465
+ '--kiln-host-uid',
466
+ String(process.getuid()),
467
+ '--kiln-host-userns',
468
+ readlinkSync('/proc/self/ns/user'),
469
+ );
449
470
  } catch {
450
471
  throw new EvaluatorIsolationReadinessError('wrapper-launch');
451
472
  }
@@ -1,5 +1,55 @@
1
1
  const CAPABILITY_STATUS_FIELDS = ['CapInh', 'CapPrm', 'CapEff', 'CapBnd', 'CapAmb'] as const;
2
2
 
3
+ export interface ProbeHostIdentity {
4
+ uid: number;
5
+ userNamespace: string;
6
+ }
7
+
8
+ const namespacePattern = /^user:\[[0-9]{1,20}\](?![\s\S])/;
9
+ const nonRootUid = (uid: number) => Number.isSafeInteger(uid) && uid > 0 && uid < 0xffff_ffff;
10
+
11
+ export function parseProbeHostIdentity(argv: readonly string[]): ProbeHostIdentity | undefined {
12
+ if (
13
+ argv.length !== 4 ||
14
+ argv[0] !== '--kiln-host-uid' ||
15
+ argv[2] !== '--kiln-host-userns' ||
16
+ !argv[1] ||
17
+ !/^[1-9][0-9]{0,9}(?![\s\S])/.test(argv[1]) ||
18
+ !argv[3] ||
19
+ !namespacePattern.test(argv[3])
20
+ )
21
+ return undefined;
22
+ const uid = Number(argv[1]);
23
+ return nonRootUid(uid) ? { uid, userNamespace: argv[3] } : undefined;
24
+ }
25
+
26
+ export function processUserNamespaceIsIsolated(
27
+ current: { uid: number; effectiveUid: number; userNamespace?: string; uidMap?: string },
28
+ host: ProbeHostIdentity | undefined,
29
+ ): boolean {
30
+ if (
31
+ !host ||
32
+ !nonRootUid(host.uid) ||
33
+ !namespacePattern.test(host.userNamespace) ||
34
+ current.uid !== host.uid ||
35
+ current.effectiveUid !== host.uid ||
36
+ typeof current.userNamespace !== 'string' ||
37
+ !namespacePattern.test(current.userNamespace) ||
38
+ current.userNamespace === host.userNamespace ||
39
+ typeof current.uidMap !== 'string' ||
40
+ current.uidMap.length > 256
41
+ )
42
+ return false;
43
+ const map = /^\s*([0-9]{1,10})\s+([0-9]{1,10})\s+([0-9]{1,10})\s*$/.exec(current.uidMap);
44
+ if (!map) return false;
45
+ const [, inside, outside, count] = map.map(Number);
46
+ // --disable-userns creates a second user namespace. With --dev, that maps
47
+ // our non-root UID to UID 0 in Bubblewrap's intermediate namespace, not host
48
+ // root. Require a different namespace, the exact unprivileged caller identity
49
+ // and a single-ID map; never accept an initial-namespace or broad mapping.
50
+ return inside === host.uid && count === 1 && (outside === 0 || outside === host.uid);
51
+ }
52
+
3
53
  /** Require every capability set exposed by /proc to be present and empty. */
4
54
  export function processCapabilitiesAreEmpty(status: string | undefined): boolean {
5
55
  if (typeof status !== 'string') return false;
@@ -1,9 +1,13 @@
1
- import { readFile, writeFile } from 'node:fs/promises';
1
+ import { readFile, readlink, writeFile } from 'node:fs/promises';
2
2
  import { connect } from 'node:net';
3
3
  import { networkInterfaces } from 'node:os';
4
4
  import { validate } from '../validation';
5
5
  import { writeFileSync } from 'node:fs';
6
- import { processCapabilitiesAreEmpty } from './probe-invariants';
6
+ import {
7
+ parseProbeHostIdentity,
8
+ processCapabilitiesAreEmpty,
9
+ processUserNamespaceIsIsolated,
10
+ } from './probe-invariants';
7
11
 
8
12
  const VERSION = 'kiln.evaluator.isolation-readiness.v1';
9
13
  type ProbeFailure =
@@ -105,12 +109,14 @@ function groupedFailure(failedChecks: readonly string[]): ProbeFailure {
105
109
  }
106
110
 
107
111
  function generatedDenials(): boolean {
112
+ if (Object.hasOwn(Object.prototype, '__proto__')) return false;
108
113
  const prelude = "const meta = { name: 'probe' }; function build(){ return createRoot('probe'); }";
109
114
  const probes = [
110
115
  `const leak = process.env; ${prelude}`,
111
116
  `const leak = fetch('http://169.254.169.254/'); ${prelude}`,
112
117
  `const leak = globalThis.process.mainModule.require('fs'); ${prelude}`,
113
118
  `const leak = globalThis.process.mainModule.require('child_process'); ${prelude}`,
119
+ `const leak = ({}).constructor.constructor('return process')(); ${prelude}`,
114
120
  ];
115
121
  return probes.every((source) => !validate(source).valid);
116
122
  }
@@ -119,11 +125,23 @@ async function main(): Promise<void> {
119
125
  const status = await optionalRead('/proc/self/status');
120
126
  const uidMap = await optionalRead('/proc/self/uid_map');
121
127
  const envKeys = Object.keys(process.env).sort();
122
- const outsideUid = Number(uidMap?.trim().split(/\s+/)[1]);
128
+ const userNamespace = await readlink('/proc/self/ns/user').catch(() => undefined);
129
+ const hostIdentity = parseProbeHostIdentity(process.argv.slice(2));
123
130
  const interfaceMap = isolationInterfaces();
124
131
  const interfaces = interfaceMap ? Object.values(interfaceMap).flat().filter(Boolean) : undefined;
125
132
  const checks: Array<[string, boolean]> = [
126
- ['user-namespace', Number.isInteger(outsideUid) && outsideUid > 0],
133
+ [
134
+ 'user-namespace',
135
+ processUserNamespaceIsIsolated(
136
+ {
137
+ uid: process.getuid?.() ?? -1,
138
+ effectiveUid: process.geteuid?.() ?? -1,
139
+ uidMap,
140
+ userNamespace,
141
+ },
142
+ hostIdentity,
143
+ ),
144
+ ],
127
145
  ['no-new-privileges', typeof status === 'string' && /^NoNewPrivs:\s+1$/m.test(status)],
128
146
  ['capabilities-empty', processCapabilitiesAreEmpty(status)],
129
147
  ['environment-exact', envKeys.join(',') === 'NODE_ENV,NO_COLOR'],
package/src/mcp-core.ts CHANGED
@@ -65,6 +65,9 @@ export const MCP_SERVER_VERSION = ENGINE_VERSION;
65
65
  export const MCP_SERVER_INSTRUCTIONS =
66
66
  "Kiln turns JavaScript you write into GLB assets and returns rendered views. Work by reference: send a program once to kiln_validate or kiln_render, keep the returned programRef exactly, and use it for every later render, inspect, kiln_source and kiln_edit call; never resend a program to change part of it. Call kiln_discover first for helper contracts, with capabilities:true for the host's runtime, project and camera facts. Read viewFidelity before judging materials: a CPU view shows shape, not material; GPU views need the render service in render-service/. Agent Skills for authoring, refining, QA and scenes ship in skills/ beside this server's dist/; read the matching SKILL.md first.";
67
67
 
68
+ export const MCP_OPERATION_INSTRUCTIONS =
69
+ 'Kiln turns authored JavaScript into editable GLB assets and rendered views. Send source once to kiln_validate or kiln_render, then preserve the exact programRef for later reads, edits and renders. Use kiln_discover for helper contracts and full input shapes; use kiln_capabilities for host configuration and limits. Read viewFidelity before judging materials: CPU geometry views are not material evidence. Search saved assets with kiln_assets_search; reopen exact source with kiln_assets_restore. Browse materials with kiln_material_search and create with kiln_material_create. Save only when requested. Pin collection, asset and revision IDs for reuse and export.';
70
+
68
71
  /** One MCP content block. Mirrors the SDK's `CallToolResult['content']` element. */
69
72
  export type KilnContentBlock =
70
73
  | AssetLink
package/src/mcp-engine.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  type KilnResourceContents,
27
27
  type KilnToolResult,
28
28
  withoutLocalPaths,
29
+ MCP_OPERATION_INSTRUCTIONS,
29
30
  } from './mcp-core';
30
31
  import { buildMcpManifest } from './mcp-manifest';
31
32
  import { localProgramStore } from './program-store-node';
@@ -33,6 +34,7 @@ import { validateRequirementsBinding } from './requirements-context';
33
34
  import { describeInputError, schemaFields } from './tools/actions';
34
35
  import {
35
36
  createKilnProgramToolRegistry,
37
+ createKilnOperationToolRegistry,
36
38
  type KilnToolContext,
37
39
  type KilnToolDef,
38
40
  } from './tools/registry';
@@ -42,6 +44,8 @@ export type { KilnToolResult } from './mcp-core';
42
44
 
43
45
  /** Optional wire features whose support is not negotiated by core MCP. */
44
46
  export type KilnMcpCompatibilityOptions = {
47
+ /** Opt-in domain operation presentation generated from the shared registry. */
48
+ toolPresentation?: 'grouped' | 'operations';
45
49
  /**
46
50
  * Add core `resource_link` blocks for saved artifact files.
47
51
  *
@@ -160,7 +164,10 @@ export function createKilnToolHost(
160
164
  };
161
165
  },
162
166
  };
163
- const defs = kilnMcpToolDefs(requestContext);
167
+ const defs =
168
+ options.toolPresentation === 'operations'
169
+ ? createKilnOperationToolRegistry(requestContext)
170
+ : kilnMcpToolDefs(requestContext);
164
171
  const byName = new Map(defs.map((def) => [def.name, def] as const));
165
172
  return {
166
173
  defs,
@@ -259,6 +266,9 @@ export function createKilnMcpServer(
259
266
  ): Server {
260
267
  const host = createKilnToolHost(context, options);
261
268
  return createKilnServer({
269
+ ...(options.toolPresentation === 'operations'
270
+ ? { instructions: MCP_OPERATION_INSTRUCTIONS }
271
+ : {}),
262
272
  manifest: buildMcpManifest(host.defs, context),
263
273
  host: async () => host,
264
274
  });
package/src/mcp.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** Node embedding API. The stdio executable retains its separate lazy entry. */
2
+ export { createKilnMcpServer, createKilnToolHost, kilnMcpToolDefs } from './mcp-engine';
3
+ export type { KilnMcpCompatibilityOptions, KilnToolResult } from './mcp-engine';
4
+ export type { KilnMcpHost, KilnContentBlock, KilnResourceContents } from './mcp-core';
5
+ export type { KilnToolContext, KilnToolDef } from './tools/registry';
@@ -264,7 +264,7 @@ export async function startLocalRenderService(
264
264
  // A failed spawn has no such race; absent/unknown never count as success.
265
265
  if (spawnError || (exitedAt !== undefined && Date.now() - exitedAt >= 1500))
266
266
  throw new Error(
267
- `render service exited during startup${spawnError ? `: ${spawnError.message}` : ''}${stderr.trim() ? `: ${stderr.trim().split('\n').slice(-3).join(' ')}` : windowsLaunch ? '; launch the renderer manually to inspect native-driver errors' : ''}`,
267
+ `render service exited during startup${spawnError ? `: ${spawnError.message}` : ''}${stderr.trim() ? `: ${stderr.trim()}` : windowsLaunch ? '; launch the renderer manually to inspect native-driver errors' : ''}`,
268
268
  );
269
269
  await new Promise((done) => setTimeout(done, 250));
270
270
  }
@@ -11,10 +11,14 @@ import { resolveGltfExporter } from '../community-exporter';
11
11
  import { approvedTextureCatalogV1 } from '../material-resources';
12
12
 
13
13
  /** Shared native tool definition; all transports use the same Discovery service. */
14
- export function createKilnDiscoveryDef(context: KilnToolContext): KilnToolDef {
14
+ export function createKilnDiscoveryDef(
15
+ context: KilnToolContext,
16
+ presentation: 'grouped' | 'operations' = 'grouped',
17
+ ): KilnToolDef {
15
18
  const run = createDiscovery(
16
- () => currentCapabilities(context),
19
+ () => currentCapabilities(context, presentation),
17
20
  async () => projectNotes(context),
21
+ presentation === 'operations' ? 'kiln_capabilities({})' : 'capabilities:true',
18
22
  );
19
23
  return {
20
24
  name: 'kiln_discover',
@@ -48,7 +52,10 @@ function projectNotes(context: KilnToolContext): string[] {
48
52
  : [];
49
53
  }
50
54
 
51
- async function currentCapabilities(context: KilnToolContext) {
55
+ async function currentCapabilities(
56
+ context: KilnToolContext,
57
+ presentation: 'grouped' | 'operations',
58
+ ) {
52
59
  const externalEvaluator =
53
60
  Boolean(context.evaluatorPort) || context.evaluatorProfile === 'evaluator-required';
54
61
  const approvedTextures = context.approvedTextureResources
@@ -102,12 +109,15 @@ async function currentCapabilities(context: KilnToolContext) {
102
109
  // you -- so the workspace guide's "do not substitute it silently" had
103
110
  // nothing to check against. Compare with `runtime` in .kiln/workspace.json.
104
111
  engine: engineIdentity(),
105
- project: {
106
- configured: configuredProject(context),
107
- select:
108
- 'projectId per call; omitted uses the configured project, null selects standalone work',
109
- read: 'kiln_project { action: "get", projectId } returns the brief, design, inventory and material pins',
110
- },
112
+ project:
113
+ presentation === 'operations' && !context.projectStore
114
+ ? { available: false }
115
+ : {
116
+ configured: configuredProject(context),
117
+ select:
118
+ 'projectId per call; omitted uses the configured project, null selects standalone work',
119
+ read: 'kiln_project { action: "get", projectId } returns the brief, design, inventory and material pins',
120
+ },
111
121
  renderer,
112
122
  execution:
113
123
  context.localExecution ??
@@ -128,7 +138,7 @@ async function currentCapabilities(context: KilnToolContext) {
128
138
  available: Boolean(context.assetLibrary),
129
139
  collections: context.assetLibrary?.collections() ?? [],
130
140
  save: 'kiln_save persists exact GLB, source and provenance; draft renders do not populate collections',
131
- resume: 'kiln_assets action=restore imports a saved revision into the current program store',
141
+ resume: `${presentation === 'operations' ? 'kiln_assets_restore' : 'kiln_assets action=restore'} imports a saved revision into the current program store`,
132
142
  downloads: 'kiln_export returns GLB/source/ZIP resource links; client presentation varies',
133
143
  viewer:
134
144
  'kiln_present opens a saved revision in supporting chat clients with 3D viewing and downloads; kiln view opens a local collection or standalone GLB/ZIP',
@@ -14,6 +14,7 @@ import { createMaterialRecordV1, decodeMaterialLibraryPayload } from '../materia
14
14
  import { createMaterialPresetDraft, listMaterialPresets } from '../material-presets';
15
15
  import { nestedRecordDescription, requireActionFields } from './actions';
16
16
  import type { KilnToolDef } from './registry';
17
+ import type { KilnActionContract } from './operation-contract';
17
18
 
18
19
  const MATERIAL_ACTIONS = [
19
20
  'presets',
@@ -24,13 +25,43 @@ const MATERIAL_ACTIONS = [
24
25
  'import',
25
26
  ] as const;
26
27
  type MaterialAction = (typeof MATERIAL_ACTIONS)[number];
27
- const REQUIREMENTS: Record<MaterialAction, Parameters<typeof requireActionFields>[3]> = {
28
- presets: { required: [] },
29
- 'create-preset': { required: ['presetId', 'seed', 'creator', 'license'] },
30
- list: { required: [] },
31
- get: { required: ['materialId'] },
32
- 'create-procedural': { required: ['draft'], shapes: { draft: 'shape:material-draft' } },
33
- import: { required: ['payload'], shapes: { payload: 'shape:material-import' } },
28
+ const MATERIAL_CONTRACT: Record<MaterialAction, KilnActionContract> = {
29
+ presets: {
30
+ fields: ['tag'],
31
+ required: [],
32
+ description: 'List shipped material recipes, optionally filtered by tag.',
33
+ },
34
+ 'create-preset': {
35
+ fields: ['presetId', 'seed', 'creator', 'license', 'size', 'materialId'],
36
+ required: ['presetId', 'seed', 'creator', 'license'],
37
+ description:
38
+ 'Create an immutable material from a shipped preset with explicit seed, creator and license.',
39
+ },
40
+ list: {
41
+ fields: ['tag'],
42
+ required: [],
43
+ description: 'List saved immutable material revisions, optionally filtered by tag.',
44
+ },
45
+ get: {
46
+ fields: ['materialId', 'revisionId'],
47
+ required: ['materialId'],
48
+ description:
49
+ 'Read a material revision, provenance and portable specification. Omit revisionId only when exactly one revision exists.',
50
+ },
51
+ 'create-procedural': {
52
+ fields: ['draft'],
53
+ required: ['draft'],
54
+ shapes: { draft: 'shape:material-draft' },
55
+ description:
56
+ 'Create an immutable material from a bounded procedural draft. Does not fetch URLs or execute source.',
57
+ },
58
+ import: {
59
+ fields: ['payload'],
60
+ required: ['payload'],
61
+ shapes: { payload: 'shape:material-import' },
62
+ description:
63
+ 'Import complete normalized material records and embedded PNGs. Does not fetch URLs or execute source.',
64
+ },
34
65
  };
35
66
  const record = (description: string) =>
36
67
  z.record(z.string(), z.unknown()).optional().describe(description);
@@ -74,9 +105,9 @@ const materialResult = (material: MaterialManifestV1) => ({
74
105
  material,
75
106
  portableSpec: materialLibraryPortableSpec(material),
76
107
  });
77
- const unknownMaterial = (materialId: string, revisionId?: string) =>
108
+ const unknownMaterial = (materialId: string, revisionId: string | undefined, listCall: string) =>
78
109
  new Error(
79
- `Unknown material ${materialId}${revisionId ? ` at ${revisionId}` : ''}. kiln_material { action: 'list' } lists the materials in this workspace with their revision IDs.`,
110
+ `Unknown material ${materialId}${revisionId ? ` at ${revisionId}` : ''}. ${listCall} lists the materials in this workspace with their revision IDs.`,
80
111
  );
81
112
  /**
82
113
  * The revision a `get` without `revisionId` means: the material's only one.
@@ -84,13 +115,17 @@ const unknownMaterial = (materialId: string, revisionId?: string) =>
84
115
  * caller to choose (two live sessions of 1 October 2026 called get with the
85
116
  * id alone and were refused).
86
117
  */
87
- async function onlyRevision(library: MaterialLibrary, materialId: string): Promise<string> {
118
+ async function onlyRevision(
119
+ library: MaterialLibrary,
120
+ materialId: string,
121
+ listCall: string,
122
+ ): Promise<string> {
88
123
  const revisions = (await library.list())
89
124
  .filter((material) => material.materialId === materialId)
90
125
  .map((material) => material.revisionId)
91
126
  .sort();
92
127
  if (revisions.length === 1) return revisions[0]!;
93
- if (revisions.length === 0) throw unknownMaterial(materialId);
128
+ if (revisions.length === 0) throw unknownMaterial(materialId, undefined, listCall);
94
129
  throw new Error(
95
130
  `Material ${materialId} has ${revisions.length} revisions: ${revisions.join(', ')}. Pass revisionId to get one.`,
96
131
  );
@@ -111,12 +146,20 @@ function aliasMaterialId(raw: unknown): unknown {
111
146
  );
112
147
  return { ...rest, materialId: resourceId };
113
148
  }
114
- export function createKilnMaterialDef(library: MaterialLibrary): KilnToolDef {
149
+ export function createKilnMaterialDef(
150
+ library: MaterialLibrary,
151
+ presentation: 'grouped' | 'operations' = 'grouped',
152
+ ): KilnToolDef {
153
+ const listCall =
154
+ presentation === 'operations'
155
+ ? 'kiln_material_search({ scope: "saved" })'
156
+ : "kiln_material { action: 'list' }";
115
157
  return {
116
158
  name: 'kiln_material',
117
159
  description:
118
160
  'Manage optional immutable material resources in this workspace. presets discovers shipped architecture, wood, metal, fabric and ground recipes; create-preset bakes one with an explicit seed, creator and license; list returns compact material/revision summaries; get returns full provenance, hashes, map conventions, physical repeat scale and a code-ready portable material spec; create-procedural bakes bounded editable layer recipes including optional height-derived normals; import accepts complete normalized records with embedded PNG bytes. No action downloads URLs or executes source. Pin the returned materialId/revisionId through per-invocation materialDependencies or project dependencies before authored evaluation resolves the resources; no project is required.',
119
161
  inputSchema: materialToolInput,
162
+ actionContract: MATERIAL_CONTRACT,
120
163
  annotations: {
121
164
  readOnlyHint: false,
122
165
  destructiveHint: false,
@@ -126,7 +169,7 @@ export function createKilnMaterialDef(library: MaterialLibrary): KilnToolDef {
126
169
  async run(raw) {
127
170
  assertMaterialJson(raw);
128
171
  const input = materialToolInput.parse(aliasMaterialId(raw));
129
- requireActionFields('kiln_material', input.action, input, REQUIREMENTS[input.action]);
172
+ requireActionFields('kiln_material', input.action, input, MATERIAL_CONTRACT[input.action]);
130
173
  switch (input.action) {
131
174
  case 'presets':
132
175
  return {
@@ -170,13 +213,14 @@ export function createKilnMaterialDef(library: MaterialLibrary): KilnToolDef {
170
213
  };
171
214
  }
172
215
  case 'get': {
173
- const revisionId = input.revisionId ?? (await onlyRevision(library, input.materialId!));
216
+ const revisionId =
217
+ input.revisionId ?? (await onlyRevision(library, input.materialId!, listCall));
174
218
  try {
175
219
  return materialResult((await library.read(input.materialId!, revisionId)).manifest);
176
220
  } catch (error) {
177
221
  // Rule 9: an unknown id names the call that lists the known ones.
178
222
  if ((error as NodeJS.ErrnoException).code === 'ENOENT')
179
- throw unknownMaterial(input.materialId!, revisionId);
223
+ throw unknownMaterial(input.materialId!, revisionId, listCall);
180
224
  throw error;
181
225
  }
182
226
  }
@@ -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
+ }