@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +60 -3
- package/CONTRIBUTING.md +16 -7
- package/README.md +95 -381
- package/dist/agent-run.mjs +220 -150
- package/dist/build.json +40 -28
- package/dist/cli.mjs +242 -163
- package/dist/mcp-engine.mjs +412 -154
- package/dist/mcp-server.mjs +1 -1
- package/docs/architecture.md +6 -3
- package/docs/chatgpt.md +20 -5
- package/docs/examples.md +40 -170
- package/docs/install.md +100 -35
- package/docs/migration.md +20 -0
- package/docs/sdk.md +49 -5
- package/docs/tools-operations.md +1529 -0
- package/lib/asset-widget-uri.d.ts +9 -0
- package/lib/asset-widget-uri.js +9 -0
- package/lib/cli-render-mode.js +10 -3
- package/lib/discovery/index.d.ts +1 -1
- package/lib/discovery/index.js +2 -2
- package/lib/discovery/portable.d.ts +9 -0
- package/lib/discovery/portable.js +6 -0
- package/lib/discovery/service.d.ts +3 -1
- package/lib/discovery/service.js +4 -2
- package/lib/engine-identity.d.ts +1 -1
- package/lib/engine-identity.js +1 -1
- package/lib/evaluator/isolation.js +19 -5
- package/lib/evaluator/probe-invariants.d.ts +11 -0
- package/lib/evaluator/probe-invariants.js +36 -0
- package/lib/evaluator/probe-worker.js +16 -4
- package/lib/loopback.d.ts +6 -0
- package/lib/loopback.js +6 -0
- package/lib/mcp-core.d.ts +210 -0
- package/lib/mcp-core.js +279 -0
- package/lib/mcp-engine.d.ts +59 -0
- package/lib/mcp-engine.js +289 -0
- package/lib/mcp-manifest.d.ts +15 -0
- package/lib/mcp-manifest.js +65 -0
- package/lib/mcp.d.ts +5 -0
- package/lib/mcp.js +2 -0
- package/lib/render-service-host.js +1 -1
- package/lib/tools/discovery.d.ts +1 -1
- package/lib/tools/discovery.js +11 -9
- package/lib/tools/materials.d.ts +1 -1
- package/lib/tools/materials.js +44 -14
- package/lib/tools/operation-contract.d.ts +8 -0
- package/lib/tools/operation-contract.js +37 -0
- package/lib/tools/operations.d.ts +3 -0
- package/lib/tools/operations.js +154 -0
- package/lib/tools/registry.d.ts +6 -2
- package/lib/tools/registry.js +71 -23
- package/package.json +23 -1
- package/plugin.json +1 -1
- package/plugins/kiln-engine/.claude-plugin/icon.png +0 -0
- package/plugins/kiln-engine/.claude-plugin/plugin.json +9 -3
- package/plugins/kiln-engine/PRIVACY.md +100 -0
- package/plugins/kiln-engine/README.md +103 -18
- package/plugins/kiln-engine/{scripts/setup-workspace.mjs → bin/kiln-setup-workspace.mjs} +37 -9
- package/plugins/kiln-engine/package-provenance.json +15 -8
- package/plugins/kiln-engine/plugin.json +10 -4
- package/plugins/kiln-engine/runtime.json +10 -1
- package/plugins/kiln-engine/skills/kiln-setup-workspace/SKILL.md +17 -18
- package/plugins/kiln-engine/skills/kiln-setup-workspace/references/plugin-install.md +28 -8
- package/plugins/kiln-engine/skills/kiln-setup-workspace/references/renderer-setup.md +11 -0
- package/scripts/create-workspace.mjs +71 -13
- package/scripts/workspace-config.mjs +239 -0
- package/scripts/workspace-project.mjs +384 -0
- package/scripts/workspace-transaction.mjs +504 -0
- package/skills/kiln-setup-workspace/SKILL.md +17 -18
- package/skills/kiln-setup-workspace/references/plugin-install.md +28 -8
- package/skills/kiln-setup-workspace/references/renderer-setup.md +11 -0
- package/src/cli-render-mode.ts +9 -9
- package/src/cli.ts +3 -3
- package/src/discovery/index.ts +2 -0
- package/src/discovery/portable.ts +9 -0
- package/src/discovery/service.ts +3 -1
- package/src/engine-identity.ts +1 -1
- package/src/evaluator/isolation.ts +27 -6
- package/src/evaluator/probe-invariants.ts +50 -0
- package/src/evaluator/probe-worker.ts +22 -4
- package/src/mcp-core.ts +3 -0
- package/src/mcp-engine.ts +11 -1
- package/src/mcp.ts +5 -0
- package/src/render-service-host.ts +1 -1
- package/src/tools/discovery.ts +20 -10
- package/src/tools/materials.ts +59 -15
- package/src/tools/operation-contract.ts +51 -0
- package/src/tools/operations.ts +177 -0
- package/src/tools/registry.ts +100 -20
package/lib/mcp-core.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The protocol half of the Kiln MCP server, shared by the thin stdio entry
|
|
3
|
+
* (`src/mcp-server.ts`) and the in-process server the tests build
|
|
4
|
+
* (`createKilnMcpServer` in `src/mcp-engine.ts`): identity, instructions,
|
|
5
|
+
* the request handlers over a tool manifest, cache hints and the stdin guard.
|
|
6
|
+
*
|
|
7
|
+
* Nothing here imports the engine. That is the whole point: the entry bundles
|
|
8
|
+
* this module with a generated manifest and loads `mcp-engine` on the first
|
|
9
|
+
* call that needs it, so the handshake, `server/discover` and the tool list
|
|
10
|
+
* are answered in the time it takes Node to load the protocol library, not
|
|
11
|
+
* the time it takes to load three, zod, sharp and the registry. Codex gives
|
|
12
|
+
* an optional server one second before it goes on without its tools.
|
|
13
|
+
*
|
|
14
|
+
* The handlers are written on the low-level `Server` rather than `McpServer`
|
|
15
|
+
* because the advertised definitions must be exact bytes from the manifest
|
|
16
|
+
* (stable across processes, no library-side schema conversion at startup)
|
|
17
|
+
* and because the capabilities must say only what Kiln does: the list of
|
|
18
|
+
* tools and resources is fixed for a build, so no `listChanged` is
|
|
19
|
+
* advertised and none is acknowledged on `subscriptions/listen`.
|
|
20
|
+
*/
|
|
21
|
+
import { ProtocolError, ProtocolErrorCode, Server, } from '@modelcontextprotocol/server';
|
|
22
|
+
import { EXTENSION_ID as MCP_APPS_EXTENSION_ID, RESOURCE_MIME_TYPE as MCP_APPS_MIME_TYPE, } from '@modelcontextprotocol/ext-apps/server';
|
|
23
|
+
import { StdioServerTransport, serveStdio, } from '@modelcontextprotocol/server/stdio';
|
|
24
|
+
import { readFile } from 'node:fs/promises';
|
|
25
|
+
import { KILN_ASSET_WIDGET_URI } from "./asset-widget-uri.js";
|
|
26
|
+
import { ENGINE_VERSION } from "./engine-identity.js";
|
|
27
|
+
import { CATEGORY_MIGRATION_MESSAGE } from "./requirements-json.js";
|
|
28
|
+
/** Server identity reported in the handshake and stamped into every 2026-07-28 result. */
|
|
29
|
+
export const MCP_SERVER_NAME = 'kiln';
|
|
30
|
+
export const MCP_SERVER_VERSION = ENGINE_VERSION;
|
|
31
|
+
/**
|
|
32
|
+
* The spec's optional `instructions` field, returned by `initialize` and
|
|
33
|
+
* `server/discover`.
|
|
34
|
+
*
|
|
35
|
+
* This is the only orientation channel that survives every install shape. A
|
|
36
|
+
* user who configures the server by hand and opens an empty folder has no
|
|
37
|
+
* AGENTS.md, no CLAUDE.md and no registered skills -- but the skills do ship
|
|
38
|
+
* beside the server, so the useful thing to say is that they exist and where.
|
|
39
|
+
*
|
|
40
|
+
* At most 700 characters, with the essentials inside the first 512 (v1 contract
|
|
41
|
+
* rule 5): the text rides on every session start in every client, and the
|
|
42
|
+
* harnesses that cut it cut from the end. It names no absolute path, so the
|
|
43
|
+
* same bytes serve every installation.
|
|
44
|
+
*/
|
|
45
|
+
export const MCP_SERVER_INSTRUCTIONS = "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.";
|
|
46
|
+
export const MCP_OPERATION_INSTRUCTIONS = '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.';
|
|
47
|
+
/** Presentation hints for the asset viewer app, hidden from the language model. */
|
|
48
|
+
export const KILN_WIDGET_META = {
|
|
49
|
+
ui: { prefersBorder: true, csp: { connectDomains: [], resourceDomains: [] } },
|
|
50
|
+
'openai/widgetDescription': 'Inspect the saved 3D asset and download its GLB or editable bundle.',
|
|
51
|
+
'openai/widgetPrefersBorder': true,
|
|
52
|
+
};
|
|
53
|
+
export const KILN_WIDGET_RESOURCE = {
|
|
54
|
+
uri: KILN_ASSET_WIDGET_URI,
|
|
55
|
+
name: 'kiln-asset-viewer',
|
|
56
|
+
mimeType: MCP_APPS_MIME_TYPE,
|
|
57
|
+
description: 'Interactive Kiln asset viewer and downloads',
|
|
58
|
+
_meta: KILN_WIDGET_META,
|
|
59
|
+
};
|
|
60
|
+
export const KILN_RESOURCE_TEMPLATES = {
|
|
61
|
+
assetFile: {
|
|
62
|
+
name: 'asset-file',
|
|
63
|
+
uriTemplate: 'kiln://assets/{collection}/{asset}/{revision}/{file}',
|
|
64
|
+
description: 'Saved GLB/source/preview/manifest/bundle, or derived runtime GLB and metadata sidecar.',
|
|
65
|
+
},
|
|
66
|
+
projectPackage: {
|
|
67
|
+
name: 'project-package',
|
|
68
|
+
uriTemplate: 'kiln://projects/{project}/{revision}/{profile}',
|
|
69
|
+
description: 'Exact editable project bundle or runtime derivative ZIP.',
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Definitions, templates and the viewer page are fixed for a build, and a new
|
|
74
|
+
* build restarts the process, so a client may hold them for a day. Asset and
|
|
75
|
+
* project files are a user's own data: never shared, and re-read on demand.
|
|
76
|
+
*/
|
|
77
|
+
export const DEFINITION_TTL_MS = 86_400_000;
|
|
78
|
+
const CACHE_HINTS = {
|
|
79
|
+
'server/discover': { ttlMs: DEFINITION_TTL_MS, cacheScope: 'public' },
|
|
80
|
+
'tools/list': { ttlMs: DEFINITION_TTL_MS, cacheScope: 'public' },
|
|
81
|
+
'resources/list': { ttlMs: DEFINITION_TTL_MS, cacheScope: 'public' },
|
|
82
|
+
'resources/templates/list': { ttlMs: DEFINITION_TTL_MS, cacheScope: 'public' },
|
|
83
|
+
'resources/read': { ttlMs: 0, cacheScope: 'private' },
|
|
84
|
+
};
|
|
85
|
+
/** Only what Kiln does: no `listChanged`, because the lists never change within a build. */
|
|
86
|
+
export function kilnServerCapabilities() {
|
|
87
|
+
return {
|
|
88
|
+
tools: {},
|
|
89
|
+
resources: {},
|
|
90
|
+
extensions: { [MCP_APPS_EXTENSION_ID]: { mimeTypes: [MCP_APPS_MIME_TYPE] } },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const LOCAL_PATH = /(?<![A-Za-z0-9])(?:[A-Za-z]:[\\/]|\\\\[^\s"'`]+|\/(?:home|Users|tmp|var|opt|mnt|root|srv|private)\/)[^\s"'`)\]]*/gu;
|
|
94
|
+
/**
|
|
95
|
+
* Rule 9 of the v1 contract: a diagnostic names the cause and the next call
|
|
96
|
+
* and carries no absolute local path. Raw filesystem errors do carry one, so
|
|
97
|
+
* the loading diagnostics pass through here before they reach a tool result.
|
|
98
|
+
*/
|
|
99
|
+
export function withoutLocalPaths(text) {
|
|
100
|
+
return text.replace(LOCAL_PATH, '<local path>');
|
|
101
|
+
}
|
|
102
|
+
export function errorMessage(error) {
|
|
103
|
+
return error instanceof Error ? error.message : String(error);
|
|
104
|
+
}
|
|
105
|
+
async function readPackagedWidgetHtml() {
|
|
106
|
+
return readFile(new URL('../dist/viewer/chat.html', import.meta.url), 'utf8');
|
|
107
|
+
}
|
|
108
|
+
/** Build the server over a manifest; every call that needs the engine goes through `host()`. */
|
|
109
|
+
export function createKilnServer(options) {
|
|
110
|
+
const { manifest } = options;
|
|
111
|
+
const tools = new Map(manifest.tools.map((tool) => [tool.name, tool]));
|
|
112
|
+
const templatePrefixes = manifest.resourceTemplates.map((template) => template.uriTemplate.slice(0, template.uriTemplate.indexOf('{')));
|
|
113
|
+
const server = new Server({ name: MCP_SERVER_NAME, version: MCP_SERVER_VERSION }, {
|
|
114
|
+
instructions: options.instructions ?? MCP_SERVER_INSTRUCTIONS,
|
|
115
|
+
capabilities: kilnServerCapabilities(),
|
|
116
|
+
cacheHints: CACHE_HINTS,
|
|
117
|
+
});
|
|
118
|
+
let listed = false;
|
|
119
|
+
server.setRequestHandler('tools/list', () => {
|
|
120
|
+
if (!listed) {
|
|
121
|
+
listed = true;
|
|
122
|
+
// After the return: the answer is written before anything else happens.
|
|
123
|
+
if (options.afterFirstToolList)
|
|
124
|
+
queueMicrotask(options.afterFirstToolList);
|
|
125
|
+
}
|
|
126
|
+
return { tools: manifest.tools };
|
|
127
|
+
});
|
|
128
|
+
server.setRequestHandler('tools/call', async (request, ctx) => {
|
|
129
|
+
const name = request.params.name;
|
|
130
|
+
const tool = tools.get(name);
|
|
131
|
+
if (!tool) {
|
|
132
|
+
// Unknown tools are protocol errors, as the spec asks; a model that
|
|
133
|
+
// misspells a name gets the list to pick from.
|
|
134
|
+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unknown tool ${name}. This server provides: ${manifest.tools.map((t) => t.name).join(', ')}.`);
|
|
135
|
+
}
|
|
136
|
+
let host;
|
|
137
|
+
try {
|
|
138
|
+
host = await options.host();
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
// The engine could not be loaded or configured. A result, not a dead
|
|
142
|
+
// server: the agent reads the next step and the session lives.
|
|
143
|
+
return { isError: true, content: [{ type: 'text', text: errorMessage(error) }] };
|
|
144
|
+
}
|
|
145
|
+
const result = await host.callTool(name, request.params.arguments ?? {}, {
|
|
146
|
+
signal: ctx.mcpReq.signal,
|
|
147
|
+
});
|
|
148
|
+
return server.projectCallToolResult(result, tool.outputSchema);
|
|
149
|
+
});
|
|
150
|
+
server.setRequestHandler('resources/list', () => ({ resources: manifest.resources }));
|
|
151
|
+
server.setRequestHandler('resources/templates/list', () => ({
|
|
152
|
+
resourceTemplates: manifest.resourceTemplates,
|
|
153
|
+
}));
|
|
154
|
+
server.setRequestHandler('resources/read', async (request) => {
|
|
155
|
+
const uri = request.params.uri;
|
|
156
|
+
if (uri === KILN_ASSET_WIDGET_URI) {
|
|
157
|
+
return {
|
|
158
|
+
contents: [
|
|
159
|
+
{
|
|
160
|
+
uri,
|
|
161
|
+
mimeType: MCP_APPS_MIME_TYPE,
|
|
162
|
+
text: await (options.widgetHtml ?? readPackagedWidgetHtml)(),
|
|
163
|
+
_meta: KILN_WIDGET_META,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
// The page is fixed for a build and carries no user data.
|
|
167
|
+
ttlMs: DEFINITION_TTL_MS,
|
|
168
|
+
cacheScope: 'public',
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (!templatePrefixes.some((prefix) => uri.startsWith(prefix))) {
|
|
172
|
+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource not found: ${uri}. Readable URIs come from kiln_save, kiln_present, kiln_export and kiln_project results and follow ${manifest.resourceTemplates.map((t) => t.uriTemplate).join(' or ')}.`);
|
|
173
|
+
}
|
|
174
|
+
let host;
|
|
175
|
+
try {
|
|
176
|
+
host = await options.host();
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
throw new ProtocolError(ProtocolErrorCode.InternalError, errorMessage(error));
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
return { contents: [await host.readResource(uri)] };
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, errorMessage(error));
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
return server;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Only explicit host startup configuration can supply a standalone session's
|
|
192
|
+
* policy. Parsed before the engine, because a wrong command line is the host's
|
|
193
|
+
* mistake and the only honest answer is an exit with the message.
|
|
194
|
+
*/
|
|
195
|
+
export function parseMcpArguments(argv) {
|
|
196
|
+
let file;
|
|
197
|
+
for (let index = 0; index < argv.length; index++) {
|
|
198
|
+
const option = argv[index];
|
|
199
|
+
if (option === '--category' || option?.startsWith('--category='))
|
|
200
|
+
throw new Error(CATEGORY_MIGRATION_MESSAGE);
|
|
201
|
+
if (option !== '--requirements')
|
|
202
|
+
throw new Error(`Unknown MCP option: ${option}`);
|
|
203
|
+
if (file !== undefined)
|
|
204
|
+
throw new Error('--requirements may be supplied only once.');
|
|
205
|
+
file = argv[++index];
|
|
206
|
+
if (!file || file.startsWith('-'))
|
|
207
|
+
throw new Error('--requirements requires a file path.');
|
|
208
|
+
}
|
|
209
|
+
return file === undefined ? {} : { requirementsFile: file };
|
|
210
|
+
}
|
|
211
|
+
const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion';
|
|
212
|
+
const CLIENT_CAPABILITIES_META_KEY = 'io.modelcontextprotocol/clientCapabilities';
|
|
213
|
+
/** Requests a 2025 client may send before `initialize` has been answered. */
|
|
214
|
+
const PRE_HANDSHAKE_METHODS = new Set(['initialize', 'ping']);
|
|
215
|
+
function asRequest(message) {
|
|
216
|
+
const candidate = message;
|
|
217
|
+
return typeof candidate.method === 'string' && candidate.id !== undefined
|
|
218
|
+
? candidate
|
|
219
|
+
: undefined;
|
|
220
|
+
}
|
|
221
|
+
function carriesProtocolVersion(params) {
|
|
222
|
+
const meta = params?._meta;
|
|
223
|
+
return typeof meta === 'object' && meta !== null && PROTOCOL_VERSION_META_KEY in meta;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Refuses a request that belongs to neither era: no per-request protocol
|
|
227
|
+
* version (so not a 2026-07-28 request) and no `initialize` before it (so not a
|
|
228
|
+
* 2025 session either). Under 2026-07-28 a request missing a required `_meta`
|
|
229
|
+
* field is malformed and answered -32602; under 2025-11-25 nothing but a ping
|
|
230
|
+
* may precede the handshake. The library would otherwise pin the connection to
|
|
231
|
+
* a 2025 instance and serve it, which hides a broken client.
|
|
232
|
+
*/
|
|
233
|
+
export function guardedStdioTransport(inner = new StdioServerTransport()) {
|
|
234
|
+
let handshakeSeen = false;
|
|
235
|
+
const guard = {
|
|
236
|
+
start: () => inner.start(),
|
|
237
|
+
send: (message, options) => inner.send(message, options),
|
|
238
|
+
close: () => inner.close(),
|
|
239
|
+
};
|
|
240
|
+
inner.onclose = () => guard.onclose?.();
|
|
241
|
+
inner.onerror = (error) => guard.onerror?.(error);
|
|
242
|
+
inner.onmessage = (message, extra) => {
|
|
243
|
+
const request = asRequest(message);
|
|
244
|
+
if (request &&
|
|
245
|
+
!handshakeSeen &&
|
|
246
|
+
!PRE_HANDSHAKE_METHODS.has(request.method) &&
|
|
247
|
+
!carriesProtocolVersion(request.params)) {
|
|
248
|
+
const refusal = {
|
|
249
|
+
jsonrpc: '2.0',
|
|
250
|
+
id: request.id,
|
|
251
|
+
error: {
|
|
252
|
+
code: ProtocolErrorCode.InvalidParams,
|
|
253
|
+
message: `${request.method} carries no protocol version and no initialize handshake preceded it. ` +
|
|
254
|
+
`Either send initialize first (protocol 2025-11-25 or 2025-06-18), or put params._meta["${PROTOCOL_VERSION_META_KEY}"] = "2026-07-28" ` +
|
|
255
|
+
`and params._meta["${CLIENT_CAPABILITIES_META_KEY}"] on every request.`,
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
void inner.send(refusal).catch(() => { });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (request?.method === 'initialize')
|
|
262
|
+
handshakeSeen = true;
|
|
263
|
+
guard.onmessage?.(message, extra);
|
|
264
|
+
};
|
|
265
|
+
return guard;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Serve over this process's stdio, both eras: a 2025 client opens with
|
|
269
|
+
* `initialize`, a 2026-07-28 client with `server/discover` or any request
|
|
270
|
+
* carrying the per-request envelope. The library owns the era decision; Kiln
|
|
271
|
+
* adds the pre-handshake guard above.
|
|
272
|
+
*/
|
|
273
|
+
export function serveKilnStdio(options, stdio = {}) {
|
|
274
|
+
return serveStdio(() => createKilnServer(options), {
|
|
275
|
+
legacy: 'serve',
|
|
276
|
+
transport: guardedStdioTransport(),
|
|
277
|
+
...stdio,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine half of the Kiln MCP server: the registry's tool definitions,
|
|
3
|
+
* their execution as MCP results, the resource readers, and the packaged host
|
|
4
|
+
* that `dist/mcp-server.mjs` loads on the first call that needs it.
|
|
5
|
+
*
|
|
6
|
+
* `createKilnMcpServer` builds the complete in-process server (the protocol
|
|
7
|
+
* core over this engine) for tests and embedders. The stdio executable is
|
|
8
|
+
* `src/mcp-server.ts`, which must not import this module statically: it is
|
|
9
|
+
* built beside it as `dist/mcp-engine.mjs` and imported by file URL.
|
|
10
|
+
*/
|
|
11
|
+
import type { Server } from '@modelcontextprotocol/server';
|
|
12
|
+
import { type KilnMcpHost, type KilnToolResult } from "./mcp-core.js";
|
|
13
|
+
import { type KilnToolContext, type KilnToolDef } from "./tools/registry.js";
|
|
14
|
+
export type { KilnToolResult } from "./mcp-core.js";
|
|
15
|
+
/** Optional wire features whose support is not negotiated by core MCP. */
|
|
16
|
+
export type KilnMcpCompatibilityOptions = {
|
|
17
|
+
/** Opt-in domain operation presentation generated from the shared registry. */
|
|
18
|
+
toolPresentation?: 'grouped' | 'operations';
|
|
19
|
+
/**
|
|
20
|
+
* Add core `resource_link` blocks for saved artifact files.
|
|
21
|
+
*
|
|
22
|
+
* Core MCP 2026-07-28 permits these blocks but exposes no client capability
|
|
23
|
+
* for them. They therefore remain opt-in while clients differ in how they
|
|
24
|
+
* consume them. The same resource descriptors always remain in JSON text and
|
|
25
|
+
* structured content, and every URI remains readable through resources/read.
|
|
26
|
+
*/
|
|
27
|
+
artifactResourceLinks?: boolean;
|
|
28
|
+
};
|
|
29
|
+
/** Use the shared program-aware definitions, including standalone validation. */
|
|
30
|
+
export declare function kilnMcpToolDefs(context?: KilnToolContext): KilnToolDef[];
|
|
31
|
+
/**
|
|
32
|
+
* Run one def and shape its output as MCP content.
|
|
33
|
+
*
|
|
34
|
+
* Defs carrying a `media`/`mediaMulti` extractor return images, which is the whole
|
|
35
|
+
* point of `kiln_render`: the calling agent must literally see the render, not a
|
|
36
|
+
* description of it. The JSON that accompanies an image has its embedded base64
|
|
37
|
+
* stripped by the extractor, so pixels are never double-encoded onto the wire.
|
|
38
|
+
*/
|
|
39
|
+
export declare function runTool(def: KilnToolDef, args: unknown, options?: KilnMcpCompatibilityOptions): Promise<KilnToolResult>;
|
|
40
|
+
/** The engine as the protocol core sees it: tool execution and resource reads over one context. */
|
|
41
|
+
export declare function createKilnToolHost(context?: KilnToolContext, options?: KilnMcpCompatibilityOptions): KilnMcpHost & {
|
|
42
|
+
defs: KilnToolDef[];
|
|
43
|
+
};
|
|
44
|
+
/** Build the complete in-process server: the protocol core over this context's engine. */
|
|
45
|
+
export declare function createKilnMcpServer(context?: KilnToolContext, options?: KilnMcpCompatibilityOptions): Server;
|
|
46
|
+
export interface PackagedKilnHostOptions {
|
|
47
|
+
/** The host binding read from `--requirements`, validated here once the engine is up. */
|
|
48
|
+
requirements?: unknown;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The host behind the stdio executable. Everything that used to happen before
|
|
52
|
+
* the handshake happens here instead, on the first call that needs it: the
|
|
53
|
+
* workspace check, the render mode, the renderer probe and the stores. Each
|
|
54
|
+
* failure is a message that names the next step and no local path, because it
|
|
55
|
+
* is returned to the agent as a tool result.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createPackagedKilnHost(options?: PackagedKilnHostOptions): Promise<KilnMcpHost & {
|
|
58
|
+
defs: KilnToolDef[];
|
|
59
|
+
}>;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { localAssetLibrary } from "./assets-node.js";
|
|
12
|
+
import { readAssetResource } from "./assets-resources.js";
|
|
13
|
+
import { buildRenderPort, resolveRenderMode } from "./cli-render-mode.js";
|
|
14
|
+
import { FileLiveReview } from "./live-review-node.js";
|
|
15
|
+
import { createPackagedLocalToolContext } from "./local-runtime.js";
|
|
16
|
+
import { LOOPBACK_HOSTNAMES } from "./loopback.js";
|
|
17
|
+
import { createKilnServer, errorMessage, withoutLocalPaths, MCP_OPERATION_INSTRUCTIONS, } from "./mcp-core.js";
|
|
18
|
+
import { buildMcpManifest } from "./mcp-manifest.js";
|
|
19
|
+
import { localProgramStore } from "./program-store-node.js";
|
|
20
|
+
import { validateRequirementsBinding } from "./requirements-context.js";
|
|
21
|
+
import { describeInputError, schemaFields } from "./tools/actions.js";
|
|
22
|
+
import { createKilnProgramToolRegistry, createKilnOperationToolRegistry, } from "./tools/registry.js";
|
|
23
|
+
import { localWorkspaceRoot } from "./workspace-node.js";
|
|
24
|
+
/** Use the shared program-aware definitions, including standalone validation. */
|
|
25
|
+
export function kilnMcpToolDefs(context = {}) {
|
|
26
|
+
return createKilnProgramToolRegistry(context);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Run one def and shape its output as MCP content.
|
|
30
|
+
*
|
|
31
|
+
* Defs carrying a `media`/`mediaMulti` extractor return images, which is the whole
|
|
32
|
+
* point of `kiln_render`: the calling agent must literally see the render, not a
|
|
33
|
+
* description of it. The JSON that accompanies an image has its embedded base64
|
|
34
|
+
* stripped by the extractor, so pixels are never double-encoded onto the wire.
|
|
35
|
+
*/
|
|
36
|
+
export async function runTool(def, args, options = {}) {
|
|
37
|
+
const output = await def.run(args);
|
|
38
|
+
// Rule 9: a result that reports its own failure is an error to the harness
|
|
39
|
+
// too, so the model sees it as one instead of as a successful result that
|
|
40
|
+
// happens to say ok: false.
|
|
41
|
+
const failed = typeof output === 'object' && output !== null && output.ok === false
|
|
42
|
+
? { isError: true }
|
|
43
|
+
: {};
|
|
44
|
+
// Rule 7: JSON on one line. The indented form cost 24% more tokens in the
|
|
45
|
+
// measured sessions and told the model nothing the keys do not.
|
|
46
|
+
const multi = def.mediaMulti?.(output);
|
|
47
|
+
if (multi) {
|
|
48
|
+
return {
|
|
49
|
+
...failed,
|
|
50
|
+
content: [
|
|
51
|
+
...multi.pngs.map((png) => ({
|
|
52
|
+
type: 'image',
|
|
53
|
+
data: Buffer.from(png).toString('base64'),
|
|
54
|
+
mimeType: 'image/png',
|
|
55
|
+
})),
|
|
56
|
+
{ type: 'text', text: JSON.stringify(multi.json) },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const media = def.media?.(output);
|
|
61
|
+
if (media) {
|
|
62
|
+
return {
|
|
63
|
+
...failed,
|
|
64
|
+
content: [
|
|
65
|
+
{
|
|
66
|
+
type: 'image',
|
|
67
|
+
data: Buffer.from(media.png).toString('base64'),
|
|
68
|
+
mimeType: 'image/png',
|
|
69
|
+
},
|
|
70
|
+
{ type: 'text', text: JSON.stringify(media.json) },
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// A def may render its own text when the default JSON would repeat itself.
|
|
75
|
+
const asText = def.text?.(output);
|
|
76
|
+
if (asText !== undefined)
|
|
77
|
+
return { ...failed, content: [{ type: 'text', text: asText }] };
|
|
78
|
+
const resources = output?.resources ?? [];
|
|
79
|
+
const includeResourceLinks = options.artifactResourceLinks === true;
|
|
80
|
+
const payload = resources.length && includeResourceLinks
|
|
81
|
+
? { ...output, resources: undefined }
|
|
82
|
+
: output;
|
|
83
|
+
return {
|
|
84
|
+
...failed,
|
|
85
|
+
content: [
|
|
86
|
+
{ type: 'text', text: JSON.stringify(payload) },
|
|
87
|
+
...(includeResourceLinks ? resources : []),
|
|
88
|
+
],
|
|
89
|
+
...(def.ui
|
|
90
|
+
? {
|
|
91
|
+
structuredContent: output,
|
|
92
|
+
_meta: await def.ui.data(output),
|
|
93
|
+
}
|
|
94
|
+
: {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** The engine as the protocol core sees it: tool execution and resource reads over one context. */
|
|
98
|
+
export function createKilnToolHost(context = {}, options = {}) {
|
|
99
|
+
const requests = new AsyncLocalStorage();
|
|
100
|
+
const requestContext = {
|
|
101
|
+
...context,
|
|
102
|
+
evaluationControls: () => {
|
|
103
|
+
const configured = context.evaluationControls?.() ?? {};
|
|
104
|
+
const signal = requests.getStore();
|
|
105
|
+
return {
|
|
106
|
+
...configured,
|
|
107
|
+
...(signal
|
|
108
|
+
? {
|
|
109
|
+
signal: configured.signal ? AbortSignal.any([signal, configured.signal]) : signal,
|
|
110
|
+
}
|
|
111
|
+
: {}),
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
const defs = options.toolPresentation === 'operations'
|
|
116
|
+
? createKilnOperationToolRegistry(requestContext)
|
|
117
|
+
: kilnMcpToolDefs(requestContext);
|
|
118
|
+
const byName = new Map(defs.map((def) => [def.name, def]));
|
|
119
|
+
return {
|
|
120
|
+
defs,
|
|
121
|
+
async callTool(name, args, request) {
|
|
122
|
+
const def = byName.get(name);
|
|
123
|
+
if (!def)
|
|
124
|
+
return {
|
|
125
|
+
isError: true,
|
|
126
|
+
content: [
|
|
127
|
+
{
|
|
128
|
+
type: 'text',
|
|
129
|
+
text: `${name} is not available in this session. Available: ${defs.map((d) => d.name).join(', ')}.`,
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
try {
|
|
134
|
+
return await requests.run(request.signal, () => runTool(def, args, options));
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
// A tool error is a result, not a transport failure: the calling agent
|
|
138
|
+
// should see the message and correct its program rather than lose the
|
|
139
|
+
// session. Rule 9: readable, naming the next step, with no local path.
|
|
140
|
+
return {
|
|
141
|
+
isError: true,
|
|
142
|
+
content: [
|
|
143
|
+
{
|
|
144
|
+
type: 'text',
|
|
145
|
+
text: withoutLocalPaths(describeInputError(name, err, { fields: schemaFields(def.inputSchema) }) ??
|
|
146
|
+
errorMessage(err)),
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
async readResource(uri) {
|
|
153
|
+
if (uri.startsWith('kiln://assets/')) {
|
|
154
|
+
if (!context.assetLibrary)
|
|
155
|
+
throw new Error(`No asset library serves ${uri} here.`);
|
|
156
|
+
const file = await readAssetResource(context.assetLibrary, uri);
|
|
157
|
+
return {
|
|
158
|
+
uri,
|
|
159
|
+
mimeType: file.mimeType,
|
|
160
|
+
...(file.name.endsWith('.json') || file.name.endsWith('.js')
|
|
161
|
+
? { text: resourceText(file.name, file.bytes) }
|
|
162
|
+
: { blob: Buffer.from(file.bytes).toString('base64') }),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (uri.startsWith('kiln://projects/')) {
|
|
166
|
+
if (!context.projectBundleReader)
|
|
167
|
+
throw new Error(`No project bundle reader serves ${uri} here.`);
|
|
168
|
+
const [project, revision, file, ...extra] = new URL(uri).pathname
|
|
169
|
+
.split('/')
|
|
170
|
+
.filter(Boolean)
|
|
171
|
+
.map(decodeURIComponent);
|
|
172
|
+
if (extra.length ||
|
|
173
|
+
!project ||
|
|
174
|
+
!revision ||
|
|
175
|
+
!['editable.zip', 'runtime.zip'].includes(file ?? ''))
|
|
176
|
+
throw new Error('Invalid project package URI');
|
|
177
|
+
const bytes = await context.projectBundleReader(project, revision, file === 'editable.zip' ? 'editable' : 'runtime');
|
|
178
|
+
return { uri, mimeType: 'application/zip', blob: Buffer.from(bytes).toString('base64') };
|
|
179
|
+
}
|
|
180
|
+
throw new Error(`Resource not found: ${uri}`);
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* A saved manifest is pretty-printed on disk and one line as a resource: a Codex session
|
|
186
|
+
* read 33,563 characters of one, a quarter of them line breaks and indentation (H26;
|
|
187
|
+
* decision 27 of 2 October 2026). Source files and anything that is not JSON are served
|
|
188
|
+
* as they are.
|
|
189
|
+
*/
|
|
190
|
+
function resourceText(name, bytes) {
|
|
191
|
+
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: name.endsWith('.js') }).decode(bytes);
|
|
192
|
+
if (!name.endsWith('.json'))
|
|
193
|
+
return text;
|
|
194
|
+
try {
|
|
195
|
+
return JSON.stringify(JSON.parse(text));
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return text;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** Build the complete in-process server: the protocol core over this context's engine. */
|
|
202
|
+
export function createKilnMcpServer(context = {}, options = {}) {
|
|
203
|
+
const host = createKilnToolHost(context, options);
|
|
204
|
+
return createKilnServer({
|
|
205
|
+
...(options.toolPresentation === 'operations'
|
|
206
|
+
? { instructions: MCP_OPERATION_INSTRUCTIONS }
|
|
207
|
+
: {}),
|
|
208
|
+
manifest: buildMcpManifest(host.defs, context),
|
|
209
|
+
host: async () => host,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The host behind the stdio executable. Everything that used to happen before
|
|
214
|
+
* the handshake happens here instead, on the first call that needs it: the
|
|
215
|
+
* workspace check, the render mode, the renderer probe and the stores. Each
|
|
216
|
+
* failure is a message that names the next step and no local path, because it
|
|
217
|
+
* is returned to the agent as a tool result.
|
|
218
|
+
*/
|
|
219
|
+
export async function createPackagedKilnHost(options = {}) {
|
|
220
|
+
const env = process.env;
|
|
221
|
+
const requirements = options.requirements === undefined
|
|
222
|
+
? undefined
|
|
223
|
+
: validateRequirementsBinding(options.requirements);
|
|
224
|
+
const workspace = env['KILN_WORKSPACE'];
|
|
225
|
+
if (workspace) {
|
|
226
|
+
// Keep setup code outside the runtime bundle; importing it must not turn its
|
|
227
|
+
// direct-entry check into another entrypoint for this MCP executable.
|
|
228
|
+
const setup = (await import(__rewriteRelativeImportExtension(new URL('../scripts/create-workspace.mjs', import.meta.url).href)));
|
|
229
|
+
let report;
|
|
230
|
+
try {
|
|
231
|
+
report = await setup.createWorkspace(workspace, 'claude', {
|
|
232
|
+
installation: fileURLToPath(new URL('..', import.meta.url)),
|
|
233
|
+
check: true,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
throw new Error(`The Kiln workspace could not be checked (${withoutLocalPaths(errorMessage(error))}). In the workspace folder run kiln-init . --repair from the current Kiln installation, then restart the MCP session.`);
|
|
238
|
+
}
|
|
239
|
+
if (report.status !== 'current')
|
|
240
|
+
throw new Error(`Kiln workspace is out of date${report.runtimeChanged ? ' (runtime changed)' : ''}: ${report.files
|
|
241
|
+
.filter((entry) => entry.status !== 'customized')
|
|
242
|
+
.map((entry) => entry.path)
|
|
243
|
+
.join(', ')}. In the workspace folder run kiln-init . --check, then kiln-init . --upgrade (or kiln-init . --repair if the installation moved), and restart the MCP session.`);
|
|
244
|
+
}
|
|
245
|
+
const mode = resolveRenderMode();
|
|
246
|
+
// One probe before the first connection, so no client attach waits on a network
|
|
247
|
+
// round trip -- but NOT a decision that lasts the session. `autoSpawn` hands
|
|
248
|
+
// back a port that starts the packaged renderer on the first view that needs
|
|
249
|
+
// one, which is the only ordering a user can actually achieve: the harness owns
|
|
250
|
+
// this process's lifecycle, so "start the renderer first" was never theirs to do.
|
|
251
|
+
const context = await createPackagedLocalToolContext({
|
|
252
|
+
...(await buildRenderPort(mode, env['KILN_RENDER_PORT_URL'], { autoSpawn: true })),
|
|
253
|
+
requirements,
|
|
254
|
+
assetLibrary: localAssetLibrary(),
|
|
255
|
+
...(env.KILN_LIVE_REVIEW !== 'off'
|
|
256
|
+
? {
|
|
257
|
+
liveReview: new FileLiveReview(localWorkspaceRoot(), {
|
|
258
|
+
transport: 'mcp',
|
|
259
|
+
workId: env.KILN_WORK_ITEM,
|
|
260
|
+
}),
|
|
261
|
+
}
|
|
262
|
+
: {}),
|
|
263
|
+
});
|
|
264
|
+
context.programStore = localProgramStore();
|
|
265
|
+
// Decision 12: the lean and compact defaults are compared live with the same
|
|
266
|
+
// briefs, switched per session here so the advertised schema never changes.
|
|
267
|
+
const detail = env['KILN_RESULT_DETAIL'];
|
|
268
|
+
if (detail) {
|
|
269
|
+
if (detail !== 'lean' && detail !== 'compact')
|
|
270
|
+
throw new Error('KILN_RESULT_DETAIL must be lean or compact (full is per call). Correct the variable and restart the MCP session.');
|
|
271
|
+
context.resultDetail = detail;
|
|
272
|
+
}
|
|
273
|
+
const deliveryBase = env['KILN_ASSET_DOWNLOAD_BASE_URL'];
|
|
274
|
+
if (deliveryBase) {
|
|
275
|
+
const base = new URL(deliveryBase);
|
|
276
|
+
if (base.protocol !== 'https:' &&
|
|
277
|
+
!(base.protocol === 'http:' && LOOPBACK_HOSTNAMES.includes(base.hostname)))
|
|
278
|
+
throw new Error('KILN_ASSET_DOWNLOAD_BASE_URL must use HTTPS or loopback HTTP. Correct the variable and restart the MCP session.');
|
|
279
|
+
context.assetDownloadUrls = async (collection, assetId, revisionId) => Object.fromEntries(['asset.glb', 'editable.zip', 'source.kiln.js', 'preview.png', 'manifest.json'].map((file) => [
|
|
280
|
+
file,
|
|
281
|
+
new URL(`files/${collection}/${assetId}/${revisionId}/${file}?download`, base.href.endsWith('/') ? base.href : `${base.href}/`).href,
|
|
282
|
+
]));
|
|
283
|
+
}
|
|
284
|
+
// stdout is the MCP transport; diagnostics must never touch it.
|
|
285
|
+
console.error(`kiln engine ready (${mode})`);
|
|
286
|
+
return createKilnToolHost(context, {
|
|
287
|
+
artifactResourceLinks: env['KILN_MCP_RESOURCE_LINKS'] === '1',
|
|
288
|
+
});
|
|
289
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type KilnMcpManifest, type KilnMcpManifestTemplate, type KilnMcpManifestTool } from "./mcp-core.js";
|
|
2
|
+
import { type KilnToolContext, type KilnToolDef } from "./tools/registry.js";
|
|
3
|
+
/** The input schema as advertised: an object root first, the rest as converted. */
|
|
4
|
+
export declare function toolInputJsonSchema(def: KilnToolDef): KilnMcpManifestTool['inputSchema'];
|
|
5
|
+
/** One registry definition as one `tools/list` entry, keys in the order the wire has always carried them. */
|
|
6
|
+
export declare function toolManifestEntry(def: KilnToolDef): KilnMcpManifestTool;
|
|
7
|
+
/** Which templates a context can serve: asset files need a library, project packages a bundle reader. */
|
|
8
|
+
export declare function resourceTemplatesFor(context: KilnToolContext): KilnMcpManifestTemplate[];
|
|
9
|
+
export declare function buildMcpManifest(defs: KilnToolDef[], context: KilnToolContext): KilnMcpManifest;
|
|
10
|
+
/**
|
|
11
|
+
* The manifest the packaged server advertises: the full surface with both
|
|
12
|
+
* templates, built from a context shaped like the packaged host's but with no
|
|
13
|
+
* environment of its own, so the file is the same on every machine.
|
|
14
|
+
*/
|
|
15
|
+
export declare function packagedMcpManifest(): KilnMcpManifest;
|