@nowline/mcp 0.6.0 → 0.8.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 (64) hide show
  1. package/dist/branding.d.ts +20 -0
  2. package/dist/branding.d.ts.map +1 -0
  3. package/dist/branding.js +27 -0
  4. package/dist/branding.js.map +1 -0
  5. package/dist/capabilities.d.ts +9 -0
  6. package/dist/capabilities.d.ts.map +1 -0
  7. package/dist/capabilities.js +17 -0
  8. package/dist/capabilities.js.map +1 -0
  9. package/dist/diagnostics.d.ts +59 -0
  10. package/dist/diagnostics.d.ts.map +1 -0
  11. package/dist/diagnostics.js +117 -0
  12. package/dist/diagnostics.js.map +1 -0
  13. package/dist/generated/resources.d.ts +2 -0
  14. package/dist/generated/resources.d.ts.map +1 -1
  15. package/dist/generated/resources.js +6 -1
  16. package/dist/generated/resources.js.map +1 -1
  17. package/dist/generated/ui-bundle.d.ts +5 -0
  18. package/dist/generated/ui-bundle.d.ts.map +1 -0
  19. package/dist/generated/ui-bundle.js +11 -0
  20. package/dist/generated/ui-bundle.js.map +1 -0
  21. package/dist/index.js +63 -12
  22. package/dist/index.js.map +1 -1
  23. package/dist/prompts.d.ts +3 -0
  24. package/dist/prompts.d.ts.map +1 -0
  25. package/dist/prompts.js +143 -0
  26. package/dist/prompts.js.map +1 -0
  27. package/dist/reference-cheatsheet.d.ts +2 -0
  28. package/dist/reference-cheatsheet.d.ts.map +1 -0
  29. package/dist/reference-cheatsheet.js +47 -0
  30. package/dist/reference-cheatsheet.js.map +1 -0
  31. package/dist/schema-vocab.d.ts +6 -0
  32. package/dist/schema-vocab.d.ts.map +1 -0
  33. package/dist/schema-vocab.js +55 -0
  34. package/dist/schema-vocab.js.map +1 -0
  35. package/dist/schemas.d.ts +148 -0
  36. package/dist/schemas.d.ts.map +1 -0
  37. package/dist/schemas.js +88 -0
  38. package/dist/schemas.js.map +1 -0
  39. package/dist/server.d.ts +2 -0
  40. package/dist/server.d.ts.map +1 -1
  41. package/dist/server.js +531 -121
  42. package/dist/server.js.map +1 -1
  43. package/dist/ui/entry.d.ts +2 -0
  44. package/dist/ui/entry.d.ts.map +1 -0
  45. package/dist/ui/entry.js +187 -0
  46. package/dist/ui/entry.js.map +1 -0
  47. package/dist/ui/payload.d.ts +30 -0
  48. package/dist/ui/payload.d.ts.map +1 -0
  49. package/dist/ui/payload.js +49 -0
  50. package/dist/ui/payload.js.map +1 -0
  51. package/package.json +15 -6
  52. package/src/branding.ts +26 -0
  53. package/src/capabilities.ts +25 -0
  54. package/src/diagnostics.ts +185 -0
  55. package/src/generated/resources.ts +7 -1
  56. package/src/generated/ui-bundle.ts +12 -0
  57. package/src/index.ts +75 -13
  58. package/src/prompts.ts +172 -0
  59. package/src/reference-cheatsheet.ts +47 -0
  60. package/src/schema-vocab.ts +55 -0
  61. package/src/schemas.ts +106 -0
  62. package/src/server.ts +725 -139
  63. package/src/ui/entry.ts +214 -0
  64. package/src/ui/payload.ts +63 -0
package/src/server.ts CHANGED
@@ -1,16 +1,21 @@
1
1
  // @nowline/mcp server factory.
2
2
  //
3
- // Creates and configures an McpServer instance with all eight tools and two
4
- // resources (nowline://reference + nowline://examples). The factory is shared
5
- // by the entry-point bin (npx @nowline/mcp) and the CLI's --mcp flag so both
6
- // paths expose an identical surface.
3
+ // Creates and configures an McpServer instance with all tools, resources, and
4
+ // prompts. The factory is shared by the entry-point bin (npx @nowline/mcp)
5
+ // and the CLI's --mcp flag so both paths expose an identical surface.
7
6
  //
8
- // Spec: specs/mcp.md. Plan: export_determinism s8 + s9.
7
+ // Spec: specs/mcp.md.
9
8
 
10
9
  import { promises as fs } from 'node:fs';
11
10
  import * as path from 'node:path';
11
+ import {
12
+ getUiCapability,
13
+ RESOURCE_MIME_TYPE,
14
+ registerAppResource,
15
+ registerAppTool,
16
+ } from '@modelcontextprotocol/ext-apps/server';
12
17
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
- import { collectDocumentDiagnostics, createNowlineServices, type NowlineFile } from '@nowline/core';
18
+ import { parseNowlineJson, printNowlineFile } from '@nowline/core';
14
19
  import {
15
20
  type ExportFormat,
16
21
  exportDocument,
@@ -18,75 +23,104 @@ import {
18
23
  type RenderInputs,
19
24
  } from '@nowline/export';
20
25
  import { resolveFonts } from '@nowline/export-core';
21
- import { URI } from 'langium';
26
+ import { buildShareLink } from '@nowline/share-link';
22
27
  import { z } from 'zod';
23
- import { EXAMPLES, REFERENCE_MAN_PAGE } from './generated/resources.js';
24
-
25
- // ---- Diagnostic helpers -----------------------------------------------------
28
+ import { NOWLINE_MCP_ICONS } from './branding.js';
29
+ import { CAPABILITIES } from './capabilities.js';
30
+ import {
31
+ buildDocument,
32
+ collectMcpDiagnostics,
33
+ collectMcpLayoutInsights,
34
+ DEFAULT_RENDER_WIDTH,
35
+ diagnosticsErrorBlock,
36
+ LAYOUT_INSIGHT_HINT,
37
+ REVIEW_MAX_WIDTH,
38
+ toolDescriptionWithSyntax,
39
+ } from './diagnostics.js';
40
+ import {
41
+ CONVERSIONS_GUIDE,
42
+ EXAMPLES,
43
+ type ExampleFile,
44
+ REFERENCE_MAN_PAGE,
45
+ } from './generated/resources.js';
46
+ import { PREVIEW_HTML } from './generated/ui-bundle.js';
47
+ import { registerPrompts } from './prompts.js';
48
+ import { REFERENCE_CHEATSHEET } from './reference-cheatsheet.js';
49
+ import { SCHEMA_VOCABULARY } from './schema-vocab.js';
50
+ import {
51
+ CapabilitiesOutputSchema,
52
+ ConvertOutputSchema,
53
+ CreateOutputSchema,
54
+ DeleteOutputSchema,
55
+ ExamplesOutputSchema,
56
+ ExportOutputSchema,
57
+ ListItemsOutputSchema,
58
+ ListOutputSchema,
59
+ ReadOutputSchema,
60
+ ReferenceOutputSchema,
61
+ RenderOutputSchema,
62
+ SchemaOutputSchema,
63
+ UpdateOutputSchema,
64
+ ValidateOutputSchema,
65
+ } from './schemas.js';
26
66
 
27
- interface McpDiagnostic {
28
- file: string;
29
- line: number;
30
- column: number;
31
- severity: 'error' | 'warning';
32
- code: string;
33
- message: string;
34
- }
67
+ // ---- MCP Apps UI (in-chat live preview) -------------------------------------
68
+ //
69
+ // Official MCP Apps model (SEP-1865): a pre-declared ui:// resource serves
70
+ // static HTML; the render tool declares _meta.ui.resourceUri; per-call data
71
+ // flows through the ontoolresult handshake. When an MCP Apps host is active,
72
+ // render returns a lean nowline.preview JSON payload (no inline SVG/PNG) so
73
+ // results stay under the host's ~150K inline cap. Non-apps hosts still get
74
+ // the full SVG/PNG inline (graceful degradation).
35
75
 
36
- function collectMcpDiagnostics(
37
- doc: Awaited<ReturnType<typeof buildDocument>>,
38
- filePath: string,
39
- ): McpDiagnostic[] {
40
- const raw = collectDocumentDiagnostics(doc);
41
- const out: McpDiagnostic[] = [];
42
- for (const d of raw) {
43
- if (d.origin === 'lexer' || d.origin === 'parser') {
44
- out.push({
45
- file: filePath,
46
- line: 1,
47
- column: 1,
48
- severity: 'error',
49
- code: d.origin === 'lexer' ? 'lexing-error' : 'parsing-error',
50
- message: d.error.message,
51
- });
52
- } else {
53
- const diag = d.diagnostic;
54
- const range = diag.range;
55
- out.push({
56
- file: filePath,
57
- line: (range?.start.line ?? 0) + 1,
58
- column: (range?.start.character ?? 0) + 1,
59
- severity: diag.severity === 1 ? 'error' : 'warning',
60
- code: String(diag.code ?? 'unknown'),
61
- message: diag.message,
62
- });
63
- }
64
- }
65
- return out;
66
- }
76
+ /** SEP-1865 UI extension capability id; also probed under common short keys. */
77
+ const MCP_APPS_UI_CAPABILITY = 'io.modelcontextprotocol/ui';
78
+ /** Versioned URI doubles as a cache key — bump suffix on bundle changes. */
79
+ export const PREVIEW_UI_URI = 'ui://nowline/preview-v1';
67
80
 
68
- // ---- Langium services -------------------------------------------------------
81
+ function clientSupportsAppsUi(server: McpServer): boolean {
82
+ // SEP-1724 negotiated extensions: canonical id under `extensions`.
83
+ const caps = server.server.getClientCapabilities() as
84
+ | {
85
+ experimental?: Record<string, unknown>;
86
+ extensions?: Record<string, unknown>;
87
+ }
88
+ | undefined;
89
+ if (!caps) return false;
90
+ if (getUiCapability(caps as Parameters<typeof getUiCapability>[0])) return true;
69
91
 
70
- let cachedServices: ReturnType<typeof createNowlineServices> | undefined;
71
- let docCounter = 0;
92
+ // Hosts also advertise under `experimental` or short `ui` / `apps` aliases.
93
+ const buckets = [caps.extensions, caps.experimental].filter(
94
+ (bucket): bucket is Record<string, unknown> => Boolean(bucket),
95
+ );
96
+ return buckets.some((bucket) =>
97
+ Boolean(bucket[MCP_APPS_UI_CAPABILITY] || bucket.ui || bucket.apps),
98
+ );
99
+ }
72
100
 
73
- function getServices() {
74
- if (!cachedServices) cachedServices = createNowlineServices();
75
- return cachedServices;
101
+ interface PreviewPayload {
102
+ source: string;
103
+ theme?: string;
104
+ now?: string;
105
+ width?: number;
106
+ locale?: string;
76
107
  }
77
108
 
78
- async function buildDocument(source: string) {
79
- const services = getServices();
80
- const uri = URI.parse(`memory:///mcp-${++docCounter}.nowline`);
81
- const doc = services.shared.workspace.LangiumDocumentFactory.fromString<NowlineFile>(
82
- source,
83
- uri,
84
- );
85
- await services.shared.workspace.DocumentBuilder.build([doc], { validation: true });
86
- return doc;
109
+ function leanPreviewBlock(payload: PreviewPayload) {
110
+ return {
111
+ type: 'text' as const,
112
+ text: JSON.stringify({
113
+ kind: 'nowline.preview',
114
+ source: payload.source,
115
+ theme: payload.theme,
116
+ now: payload.now,
117
+ width: payload.width,
118
+ locale: payload.locale,
119
+ }),
120
+ };
87
121
  }
88
122
 
89
- // ---- Allowed-root enforcement -----------------------------------------------
123
+ // ---- Server factory ---------------------------------------------------------
90
124
 
91
125
  function resolveAndGuard(filePath: string, allowedRoot: string): string {
92
126
  const abs = path.resolve(allowedRoot, filePath);
@@ -114,7 +148,6 @@ function createNodeHostEnv(sourcePath: string): HostEnv {
114
148
  return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
115
149
  },
116
150
  async loadWasm(): Promise<ArrayBuffer> {
117
- // Resolve resvg.wasm relative to @nowline/export-png/dist/ at runtime.
118
151
  const { createRequire } = await import('node:module');
119
152
  const req = createRequire(import.meta.url);
120
153
  const entry = req.resolve('@resvg/resvg-wasm');
@@ -133,6 +166,15 @@ function todayUtc(): Date {
133
166
  return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
134
167
  }
135
168
 
169
+ function exampleShortName(fullName: string): string {
170
+ return fullName.endsWith('.nowline') ? fullName.slice(0, -'.nowline'.length) : fullName;
171
+ }
172
+
173
+ function findExample(name: string): ExampleFile | undefined {
174
+ const withExt = name.endsWith('.nowline') ? name : `${name}.nowline`;
175
+ return EXAMPLES.find((e) => e.name === withExt || e.name === name);
176
+ }
177
+
136
178
  async function sourceAndPath(
137
179
  args: { source?: string; path?: string },
138
180
  allowedRoot: string,
@@ -161,10 +203,22 @@ export interface McpServerOptions {
161
203
 
162
204
  export function createMcpServer(opts: McpServerOptions = {}): McpServer {
163
205
  const allowedRoot = opts.allowedRoot ?? process.cwd();
164
- const server = new McpServer({
165
- name: opts.name ?? 'nowline',
166
- version: opts.version ?? '0.5.1',
167
- });
206
+ const server = new McpServer(
207
+ {
208
+ name: opts.name ?? 'nowline',
209
+ version: opts.version ?? '0.6.0',
210
+ icons: [...NOWLINE_MCP_ICONS],
211
+ },
212
+ {
213
+ instructions:
214
+ 'Nowline manages roadmaps written in the .nowline plain-text DSL — NOT JSON or any other ' +
215
+ 'structured format. All `source` parameters expect `.nowline` DSL text (starts with `nowline v1`). ' +
216
+ 'Workflow: 1. call `reference` or `examples` to learn syntax → 2. write `.nowline` → ' +
217
+ '3. call `render` (validates + renders; or `validate` alone) → 4. fix errors keyed on `NL.E####` ' +
218
+ 'and re-render → 5. review returned layout `insights` (what reflowed) → 6. when uncertain, ' +
219
+ 'call `render` with `review:true` for a final visual check. JSON in `convert` is AST conversion only.',
220
+ },
221
+ );
168
222
 
169
223
  // ---- Resources ----------------------------------------------------------
170
224
 
@@ -199,13 +253,56 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
199
253
  }),
200
254
  );
201
255
 
256
+ server.registerResource(
257
+ 'nowline-conversions',
258
+ 'nowline://conversions',
259
+ {
260
+ description:
261
+ 'LLM-mediated conversion guide: how to translate Mermaid gantt, MS Project, Excel, Google Sheets timeline, and generic CSV into Nowline DSL.',
262
+ mimeType: 'text/plain',
263
+ },
264
+ async () => ({
265
+ contents: [
266
+ {
267
+ uri: 'nowline://conversions',
268
+ text: CONVERSIONS_GUIDE,
269
+ mimeType: 'text/plain',
270
+ },
271
+ ],
272
+ }),
273
+ );
274
+
275
+ registerAppResource(
276
+ server,
277
+ 'nowline-preview',
278
+ PREVIEW_UI_URI,
279
+ {
280
+ description:
281
+ 'Interactive in-chat roadmap preview (MCP Apps). Hydrates via ontoolresult.',
282
+ },
283
+ async () => ({
284
+ contents: [
285
+ {
286
+ uri: PREVIEW_UI_URI,
287
+ mimeType: RESOURCE_MIME_TYPE,
288
+ text: PREVIEW_HTML,
289
+ },
290
+ ],
291
+ }),
292
+ );
293
+
294
+ // ---- Prompts ------------------------------------------------------------
295
+
296
+ registerPrompts(server);
297
+
202
298
  // ---- validate -----------------------------------------------------------
203
299
 
204
300
  server.registerTool(
205
301
  'validate',
206
302
  {
207
- description:
208
- 'Parse and validate a .nowline roadmap. Returns ok=true and an empty diagnostics array if valid, or ok=false with structured diagnostics.',
303
+ description: toolDescriptionWithSyntax(
304
+ 'Parse and validate a .nowline roadmap. Returns ok=true with optional layout insights when valid, or ok=false with structured diagnostics.',
305
+ ),
209
306
  inputSchema: z.object({
210
307
  source: z.string().optional().describe('Inline .nowline source text to validate.'),
211
308
  path: z
@@ -213,14 +310,33 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
213
310
  .optional()
214
311
  .describe('Absolute or relative path to a .nowline file to validate.'),
215
312
  }),
313
+ outputSchema: ValidateOutputSchema,
314
+ annotations: { readOnlyHint: true, idempotentHint: true },
216
315
  },
217
316
  async (args) => {
218
317
  const { source, filePath } = await sourceAndPath(args, allowedRoot);
219
318
  const doc = await buildDocument(source);
220
319
  const diagnostics = collectMcpDiagnostics(doc, filePath);
221
320
  const ok = diagnostics.every((d) => d.severity !== 'error');
321
+ const insights = ok
322
+ ? await collectMcpLayoutInsights({
323
+ source,
324
+ filePath,
325
+ today: todayUtc(),
326
+ locale: 'en-US',
327
+ readFile: createNodeHostEnv(filePath).readSource,
328
+ doc,
329
+ })
330
+ : [];
331
+ const structured = { ok, diagnostics, ...(insights.length > 0 ? { insights } : {}) };
222
332
  return {
223
- content: [{ type: 'text', text: JSON.stringify({ ok, diagnostics }, null, 2) }],
333
+ content: [
334
+ { type: 'text', text: JSON.stringify(structured, null, 2) },
335
+ ...(insights.length > 0
336
+ ? [{ type: 'text' as const, text: LAYOUT_INSIGHT_HINT }]
337
+ : []),
338
+ ],
339
+ structuredContent: structured,
224
340
  };
225
341
  },
226
342
  );
@@ -236,17 +352,16 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
236
352
  .string()
237
353
  .describe('Absolute or relative path to the .nowline file to read.'),
238
354
  }),
355
+ outputSchema: ReadOutputSchema,
356
+ annotations: { readOnlyHint: true, idempotentHint: true },
239
357
  },
240
358
  async (args) => {
241
359
  const abs = resolveAndGuard(args.path, allowedRoot);
242
360
  const source = await fs.readFile(abs, 'utf-8');
361
+ const structured = { path: abs, source };
243
362
  return {
244
- content: [
245
- {
246
- type: 'text',
247
- text: JSON.stringify({ path: abs, source }, null, 2),
248
- },
249
- ],
363
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
364
+ structuredContent: structured,
250
365
  };
251
366
  },
252
367
  );
@@ -256,33 +371,27 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
256
371
  server.registerTool(
257
372
  'create',
258
373
  {
259
- description:
374
+ description: toolDescriptionWithSyntax(
260
375
  'Write a new .nowline file after validation. Overwrites if the path already exists.',
376
+ ),
261
377
  inputSchema: z.object({
262
378
  path: z.string().describe('Absolute or relative path to write the .nowline file.'),
263
379
  source: z.string().describe('The .nowline source text to write.'),
264
380
  }),
381
+ outputSchema: CreateOutputSchema,
382
+ // Overwrites silently → destructive; same source always produces same file → idempotent.
383
+ annotations: { destructiveHint: true, idempotentHint: true },
265
384
  },
266
385
  async (args) => {
267
386
  const abs = resolveAndGuard(args.path, allowedRoot);
268
- const doc = await buildDocument(args.source);
269
- const diagnostics = collectMcpDiagnostics(doc, abs);
270
- const errors = diagnostics.filter((d) => d.severity === 'error');
271
- if (errors.length > 0) {
272
- return {
273
- content: [
274
- {
275
- type: 'text',
276
- text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
277
- },
278
- ],
279
- isError: true,
280
- };
281
- }
387
+ const blocked = await diagnosticsErrorBlock(args.source, abs);
388
+ if (!blocked.ok) return blocked.response;
282
389
  await fs.mkdir(path.dirname(abs), { recursive: true });
283
390
  await fs.writeFile(abs, args.source, 'utf-8');
391
+ const structured = { ok: true, path: abs };
284
392
  return {
285
- content: [{ type: 'text', text: JSON.stringify({ ok: true, path: abs }, null, 2) }],
393
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
394
+ structuredContent: structured,
286
395
  };
287
396
  },
288
397
  );
@@ -292,33 +401,27 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
292
401
  server.registerTool(
293
402
  'update',
294
403
  {
295
- description: 'Replace an existing .nowline file after validation.',
404
+ description: toolDescriptionWithSyntax(
405
+ 'Replace an existing .nowline file after validation.',
406
+ ),
296
407
  inputSchema: z.object({
297
408
  path: z
298
409
  .string()
299
410
  .describe('Absolute or relative path of the .nowline file to update.'),
300
411
  source: z.string().describe('The new .nowline source text.'),
301
412
  }),
413
+ outputSchema: UpdateOutputSchema,
414
+ annotations: { idempotentHint: true },
302
415
  },
303
416
  async (args) => {
304
417
  const abs = resolveAndGuard(args.path, allowedRoot);
305
- const doc = await buildDocument(args.source);
306
- const diagnostics = collectMcpDiagnostics(doc, abs);
307
- const errors = diagnostics.filter((d) => d.severity === 'error');
308
- if (errors.length > 0) {
309
- return {
310
- content: [
311
- {
312
- type: 'text',
313
- text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
314
- },
315
- ],
316
- isError: true,
317
- };
318
- }
418
+ const blocked = await diagnosticsErrorBlock(args.source, abs);
419
+ if (!blocked.ok) return blocked.response;
319
420
  await fs.writeFile(abs, args.source, 'utf-8');
421
+ const structured = { ok: true, path: abs };
320
422
  return {
321
- content: [{ type: 'text', text: JSON.stringify({ ok: true, path: abs }, null, 2) }],
423
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
424
+ structuredContent: structured,
322
425
  };
323
426
  },
324
427
  );
@@ -334,12 +437,16 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
334
437
  .string()
335
438
  .describe('Absolute or relative path of the .nowline file to delete.'),
336
439
  }),
440
+ outputSchema: DeleteOutputSchema,
441
+ annotations: { destructiveHint: true },
337
442
  },
338
443
  async (args) => {
339
444
  const abs = resolveAndGuard(args.path, allowedRoot);
340
445
  await fs.unlink(abs);
446
+ const structured = { path: abs };
341
447
  return {
342
- content: [{ type: 'text', text: JSON.stringify({ path: abs }, null, 2) }],
448
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
449
+ structuredContent: structured,
343
450
  };
344
451
  },
345
452
  );
@@ -362,27 +469,41 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
362
469
  .optional()
363
470
  .describe('Whether to scan subdirectories. Defaults to false.'),
364
471
  }),
472
+ outputSchema: ListOutputSchema,
473
+ annotations: { readOnlyHint: true, idempotentHint: true },
365
474
  },
366
475
  async (args) => {
367
476
  const dir = args.directory ? resolveAndGuard(args.directory, allowedRoot) : allowedRoot;
368
477
  const recursive = args.recursive ?? false;
369
478
  const paths = await listNowlineFiles(dir, recursive);
479
+ const structured = { paths };
370
480
  return {
371
- content: [{ type: 'text', text: JSON.stringify({ paths }, null, 2) }],
481
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
482
+ structuredContent: structured,
372
483
  };
373
484
  },
374
485
  );
375
486
 
376
487
  // ---- render -------------------------------------------------------------
377
488
 
378
- server.registerTool(
489
+ registerAppTool(
490
+ server,
379
491
  'render',
380
492
  {
381
- description:
382
- 'Render a .nowline roadmap to SVG or PNG using the shared export kernel. Byte-identical to `nowline -f svg/png` for the same source and inputs.',
493
+ description: toolDescriptionWithSyntax(
494
+ 'Validate then render a .nowline roadmap to SVG or PNG (combined validate+render+share). ' +
495
+ 'Returns structured diagnostics on error-severity input instead of a raw kernel error.',
496
+ ),
383
497
  inputSchema: z.object({
384
498
  source: z.string().optional().describe('Inline .nowline source text.'),
385
- path: z.string().optional().describe('Path to the .nowline file.'),
499
+ path: z
500
+ .string()
501
+ .optional()
502
+ .describe(
503
+ 'Real local filesystem path to the .nowline file (e.g. /Users/name/Desktop/foo.nowline). ' +
504
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
505
+ 'those do not exist on the host filesystem. Pass `source` instead.',
506
+ ),
386
507
  format: z
387
508
  .enum(['svg', 'png'])
388
509
  .optional()
@@ -403,11 +524,42 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
403
524
  output: z
404
525
  .string()
405
526
  .optional()
406
- .describe('Write output to this path instead of returning inline.'),
527
+ .describe(
528
+ 'Real local filesystem path to write the output file (e.g. /Users/name/Desktop/roadmap.svg). ' +
529
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
530
+ 'omit this parameter to receive output inline instead.',
531
+ ),
532
+ share: z
533
+ .boolean()
534
+ .optional()
535
+ .describe(
536
+ 'When true, include a shareUrl pointing to https://free.nowline.io/open.',
537
+ ),
538
+ review: z
539
+ .boolean()
540
+ .optional()
541
+ .describe(
542
+ 'When true, also attach a downscaled PNG so a multimodal model can visually check layout (label overflow, lane crowding, off-range now-line). Off by default.',
543
+ ),
544
+ preview: z
545
+ .boolean()
546
+ .optional()
547
+ .describe(
548
+ 'When true, force the in-chat MCP Apps preview. On MCP Apps hosts the preview auto-renders via _meta.ui without this flag.',
549
+ ),
407
550
  }),
551
+ outputSchema: RenderOutputSchema,
552
+ annotations: { readOnlyHint: true, idempotentHint: true },
553
+ _meta: {
554
+ ui: { resourceUri: PREVIEW_UI_URI },
555
+ 'openai/outputTemplate': PREVIEW_UI_URI,
556
+ },
408
557
  },
409
558
  async (args) => {
410
559
  const { source, filePath } = await sourceAndPath(args, allowedRoot);
560
+ const blocked = await diagnosticsErrorBlock(source, filePath);
561
+ if (!blocked.ok) return blocked.response;
562
+
411
563
  const format: ExportFormat = args.format ?? 'svg';
412
564
  const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
413
565
  const inputs: RenderInputs = {
@@ -418,28 +570,90 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
418
570
  width: args.width,
419
571
  pngScale: args.scale,
420
572
  };
421
- if (format === 'png') {
573
+ const host = createNodeHostEnv(filePath);
574
+ const appActive = args.preview === true || clientSupportsAppsUi(server);
575
+ // Skip the full render when the bytes won't be used: apps host with
576
+ // no write-to-disk path and no review attachment requested.
577
+ const needsRender = !appActive || !!args.output || args.review === true;
578
+ if (needsRender && (format === 'png' || args.review === true)) {
422
579
  const result = await resolveFonts({ headless: true });
423
580
  inputs.fonts = { sans: result.sans, mono: result.mono };
424
581
  }
425
- const host = createNodeHostEnv(filePath);
426
- const bytes = await exportDocument(source, format, inputs, host);
582
+ const bytes = needsRender
583
+ ? await exportDocument(source, format, inputs, host)
584
+ : new Uint8Array(0);
585
+ const shareUrl = args.share
586
+ ? (buildShareLink({ source, share: true }) ?? undefined)
587
+ : undefined;
588
+
589
+ const insights = await collectMcpLayoutInsights({
590
+ source,
591
+ filePath,
592
+ today,
593
+ theme: args.theme ?? 'light',
594
+ width: args.width,
595
+ locale: 'en-US',
596
+ readFile: host.readSource,
597
+ doc: blocked.doc,
598
+ });
599
+
600
+ const previewPayload: PreviewPayload = {
601
+ source,
602
+ theme: args.theme,
603
+ now: args.now,
604
+ width: args.width,
605
+ locale: 'en-US',
606
+ };
607
+
608
+ const reviewBlocks =
609
+ args.review === true
610
+ ? await buildReviewContentBlocks(source, format, bytes, inputs, host)
611
+ : [];
612
+
613
+ const insightHintBlocks =
614
+ insights.length > 0 ? [{ type: 'text' as const, text: LAYOUT_INSIGHT_HINT }] : [];
615
+ const insightsField = insights.length > 0 ? { insights } : {};
427
616
 
428
617
  if (args.output) {
429
618
  const outAbs = resolveAndGuard(args.output, allowedRoot);
430
619
  await fs.mkdir(path.dirname(outAbs), { recursive: true });
431
620
  await fs.writeFile(outAbs, bytes);
621
+ const structured = {
622
+ format,
623
+ path: outAbs,
624
+ bytes: bytes.byteLength,
625
+ shareUrl,
626
+ ...insightsField,
627
+ };
432
628
  return {
433
629
  content: [
434
- {
435
- type: 'text',
436
- text: JSON.stringify({ path: outAbs, bytes: bytes.byteLength }),
437
- },
630
+ ...(appActive ? [leanPreviewBlock(previewPayload)] : []),
631
+ { type: 'text' as const, text: JSON.stringify(structured, null, 2) },
632
+ ...insightHintBlocks,
633
+ ...reviewBlocks,
438
634
  ],
635
+ structuredContent: structured,
636
+ };
637
+ }
638
+
639
+ if (appActive) {
640
+ const structured = {
641
+ format,
642
+ shareUrl,
643
+ ...insightsField,
644
+ };
645
+ return {
646
+ content: [
647
+ leanPreviewBlock(previewPayload),
648
+ ...insightHintBlocks,
649
+ ...reviewBlocks,
650
+ ],
651
+ structuredContent: structured,
439
652
  };
440
653
  }
441
654
 
442
655
  if (format === 'png') {
656
+ const structured = { format, bytes: bytes.byteLength, shareUrl, ...insightsField };
443
657
  return {
444
658
  content: [
445
659
  {
@@ -447,17 +661,21 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
447
661
  data: Buffer.from(bytes).toString('base64'),
448
662
  mimeType: 'image/png',
449
663
  },
664
+ ...insightHintBlocks,
665
+ ...reviewBlocks,
450
666
  ],
667
+ structuredContent: structured,
451
668
  };
452
669
  }
670
+ const svgText = new TextDecoder('utf-8').decode(bytes);
671
+ const structured = { format, shareUrl, ...insightsField };
453
672
  return {
454
673
  content: [
455
- {
456
- type: 'text',
457
- text: new TextDecoder('utf-8').decode(bytes),
458
- mimeType: 'image/svg+xml',
459
- },
674
+ { type: 'text', text: svgText, mimeType: 'image/svg+xml' },
675
+ ...insightHintBlocks,
676
+ ...reviewBlocks,
460
677
  ],
678
+ structuredContent: structured,
461
679
  };
462
680
  },
463
681
  );
@@ -474,14 +692,26 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
474
692
  'Export a .nowline roadmap to any of the eight canonical formats. Byte-identical to `nowline -f <format>` for the same source and inputs.',
475
693
  inputSchema: z.object({
476
694
  source: z.string().optional().describe('Inline .nowline source text.'),
477
- path: z.string().optional().describe('Path to the .nowline file.'),
695
+ path: z
696
+ .string()
697
+ .optional()
698
+ .describe(
699
+ 'Real local filesystem path to the .nowline file (e.g. /Users/name/Desktop/foo.nowline). ' +
700
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
701
+ 'those do not exist on the host filesystem. Pass `source` instead.',
702
+ ),
478
703
  format: z
479
704
  .enum(EXPORT_FORMATS)
480
705
  .describe('Export format: pdf, html, mermaid, xlsx, msproj, or png.'),
481
706
  output: z
482
707
  .string()
483
708
  .optional()
484
- .describe('Path to write the output file. Required for binary formats.'),
709
+ .describe(
710
+ 'Real local filesystem path to write the output (e.g. /Users/name/Desktop/roadmap.pdf). ' +
711
+ 'Required for binary formats (pdf, xlsx, msproj, png). ' +
712
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
713
+ 'those do not exist on the host filesystem.',
714
+ ),
485
715
  now: z.string().optional().describe('Now-line date as YYYY-MM-DD (UTC).'),
486
716
  theme: z.enum(['light', 'dark', 'grayscale']).optional(),
487
717
  scale: z.number().optional().describe('PNG scale factor.'),
@@ -495,10 +725,21 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
495
725
  .string()
496
726
  .optional()
497
727
  .describe('MS Project start date override (YYYY-MM-DD).'),
728
+ share: z
729
+ .boolean()
730
+ .optional()
731
+ .describe(
732
+ 'When true, include a shareUrl pointing to https://free.nowline.io/open.',
733
+ ),
498
734
  }),
735
+ outputSchema: ExportOutputSchema,
736
+ annotations: { readOnlyHint: true, idempotentHint: true },
499
737
  },
500
738
  async (args) => {
501
739
  const { source, filePath } = await sourceAndPath(args, allowedRoot);
740
+ const blocked = await diagnosticsErrorBlock(source, filePath);
741
+ if (!blocked.ok) return blocked.response;
742
+
502
743
  const format: ExportFormat = args.format as NonRenderFormat;
503
744
  const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
504
745
  const inputs: RenderInputs = {
@@ -518,6 +759,9 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
518
759
  }
519
760
  const host = createNodeHostEnv(filePath);
520
761
  const bytes = await exportDocument(source, format, inputs, host);
762
+ const shareUrl = args.share
763
+ ? (buildShareLink({ source, share: true }) ?? undefined)
764
+ : undefined;
521
765
 
522
766
  const BINARY_FORMATS = new Set<ExportFormat>(['png', 'pdf', 'xlsx']);
523
767
  const isBinary = BINARY_FORMATS.has(format);
@@ -526,13 +770,10 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
526
770
  const outAbs = resolveAndGuard(args.output, allowedRoot);
527
771
  await fs.mkdir(path.dirname(outAbs), { recursive: true });
528
772
  await fs.writeFile(outAbs, bytes);
773
+ const structured = { format, path: outAbs, bytes: bytes.byteLength, shareUrl };
529
774
  return {
530
- content: [
531
- {
532
- type: 'text',
533
- text: JSON.stringify({ path: outAbs, bytes: bytes.byteLength }),
534
- },
535
- ],
775
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
776
+ structuredContent: structured,
536
777
  };
537
778
  }
538
779
 
@@ -542,6 +783,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
542
783
  pdf: 'application/pdf',
543
784
  xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
544
785
  };
786
+ const structured = { format, bytes: bytes.byteLength, shareUrl };
545
787
  return {
546
788
  content: [
547
789
  {
@@ -550,15 +792,310 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
550
792
  mimeType: mimeMap[format] ?? 'application/octet-stream',
551
793
  },
552
794
  ],
795
+ structuredContent: structured,
553
796
  };
554
797
  }
798
+ const text = new TextDecoder('utf-8').decode(bytes);
799
+ const structured = { format, shareUrl };
800
+ return {
801
+ content: [{ type: 'text', text }],
802
+ structuredContent: structured,
803
+ };
804
+ },
805
+ );
806
+
807
+ // ---- convert ------------------------------------------------------------
808
+
809
+ server.registerTool(
810
+ 'convert',
811
+ {
812
+ description:
813
+ 'Convert between .nowline source text and its JSON AST representation. `to:json` serializes a .nowline file to the JSON AST; `to:nowline` pretty-prints a JSON AST back to canonical .nowline source.',
814
+ inputSchema: z.object({
815
+ source: z.string().optional().describe('Inline source text to convert.'),
816
+ path: z
817
+ .string()
818
+ .optional()
819
+ .describe(
820
+ 'Real local filesystem path to the source file. ' +
821
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
822
+ 'pass `source` instead.',
823
+ ),
824
+ to: z
825
+ .enum(['json', 'nowline'])
826
+ .describe(
827
+ '"json" — serialize .nowline text to JSON AST. "nowline" — pretty-print a JSON AST back to .nowline source.',
828
+ ),
829
+ }),
830
+ outputSchema: ConvertOutputSchema,
831
+ annotations: { readOnlyHint: true, idempotentHint: true },
832
+ },
833
+ async (args) => {
834
+ if (args.to === 'json') {
835
+ const { source, filePath } = await sourceAndPath(args, allowedRoot);
836
+ const host = createNodeHostEnv(filePath);
837
+ const jsonBytes = await exportDocument(
838
+ source,
839
+ 'json',
840
+ {
841
+ sourcePath: filePath,
842
+ today: todayUtc(),
843
+ locale: 'en-US',
844
+ theme: 'light',
845
+ },
846
+ host,
847
+ );
848
+ const result = new TextDecoder('utf-8').decode(jsonBytes);
849
+ const structured = { to: 'json' as const, result };
850
+ return {
851
+ content: [{ type: 'text', text: result }],
852
+ structuredContent: structured,
853
+ };
854
+ }
855
+
856
+ // to: 'nowline' — input is a JSON AST string
857
+ const jsonSource =
858
+ args.source ??
859
+ (args.path
860
+ ? await fs.readFile(resolveAndGuard(args.path, allowedRoot), 'utf-8')
861
+ : null);
862
+ if (!jsonSource) {
863
+ throw new Error('At least one of `source` or `path` is required.');
864
+ }
865
+ const { ast } = parseNowlineJson(jsonSource, args.path ?? 'input.json');
866
+ const result = printNowlineFile(ast);
867
+ const structured = { to: 'nowline' as const, result };
868
+ return {
869
+ content: [{ type: 'text', text: result }],
870
+ structuredContent: structured,
871
+ };
872
+ },
873
+ );
874
+
875
+ // ---- capabilities -------------------------------------------------------
876
+
877
+ server.registerTool(
878
+ 'capabilities',
879
+ {
880
+ description:
881
+ 'Return all supported themes, icons, locales, export formats, and template names in a single response.',
882
+ inputSchema: z.object({}),
883
+ outputSchema: CapabilitiesOutputSchema,
884
+ annotations: { readOnlyHint: true, idempotentHint: true },
885
+ },
886
+ async () => {
887
+ const structured = {
888
+ themes: [...CAPABILITIES.themes],
889
+ icons: [...CAPABILITIES.icons],
890
+ locales: [...CAPABILITIES.locales],
891
+ formats: [...CAPABILITIES.formats],
892
+ templates: [...CAPABILITIES.templates],
893
+ };
894
+ return {
895
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
896
+ structuredContent: structured,
897
+ };
898
+ },
899
+ );
900
+
901
+ // ---- list-themes --------------------------------------------------------
902
+
903
+ server.registerTool(
904
+ 'list-themes',
905
+ {
906
+ description: 'List supported color themes: light, dark, grayscale.',
907
+ inputSchema: z.object({}),
908
+ outputSchema: ListItemsOutputSchema,
909
+ annotations: { readOnlyHint: true, idempotentHint: true },
910
+ },
911
+ async () => {
912
+ const structured = { items: [...CAPABILITIES.themes] };
913
+ return {
914
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
915
+ structuredContent: structured,
916
+ };
917
+ },
918
+ );
919
+
920
+ // ---- list-icons ---------------------------------------------------------
921
+
922
+ server.registerTool(
923
+ 'list-icons',
924
+ {
925
+ description:
926
+ 'List built-in capacity-icon names usable in the `capacity-icon:` style property.',
927
+ inputSchema: z.object({}),
928
+ outputSchema: ListItemsOutputSchema,
929
+ annotations: { readOnlyHint: true, idempotentHint: true },
930
+ },
931
+ async () => {
932
+ const structured = { items: [...CAPABILITIES.icons] };
933
+ return {
934
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
935
+ structuredContent: structured,
936
+ };
937
+ },
938
+ );
939
+
940
+ // ---- list-locales -------------------------------------------------------
941
+
942
+ server.registerTool(
943
+ 'list-locales',
944
+ {
945
+ description: 'List supported BCP-47 locale tags.',
946
+ inputSchema: z.object({}),
947
+ outputSchema: ListItemsOutputSchema,
948
+ annotations: { readOnlyHint: true, idempotentHint: true },
949
+ },
950
+ async () => {
951
+ const structured = { items: [...CAPABILITIES.locales] };
952
+ return {
953
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
954
+ structuredContent: structured,
955
+ };
956
+ },
957
+ );
958
+
959
+ // ---- list-formats -------------------------------------------------------
960
+
961
+ server.registerTool(
962
+ 'list-formats',
963
+ {
964
+ description:
965
+ 'List all supported export formats (svg, png, pdf, html, mermaid, xlsx, msproj, json).',
966
+ inputSchema: z.object({}),
967
+ outputSchema: ListItemsOutputSchema,
968
+ annotations: { readOnlyHint: true, idempotentHint: true },
969
+ },
970
+ async () => {
971
+ const structured = { items: [...CAPABILITIES.formats] };
972
+ return {
973
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
974
+ structuredContent: structured,
975
+ };
976
+ },
977
+ );
978
+
979
+ // ---- list-templates -----------------------------------------------------
980
+
981
+ server.registerTool(
982
+ 'list-templates',
983
+ {
984
+ description: 'List built-in template names usable with `nowline --init --template`.',
985
+ inputSchema: z.object({}),
986
+ outputSchema: ListItemsOutputSchema,
987
+ annotations: { readOnlyHint: true, idempotentHint: true },
988
+ },
989
+ async () => {
990
+ const structured = { items: [...CAPABILITIES.templates] };
991
+ return {
992
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
993
+ structuredContent: structured,
994
+ };
995
+ },
996
+ );
997
+
998
+ // ---- reference / examples / schema (discovery tools) --------------------
999
+
1000
+ server.registerTool(
1001
+ 'reference',
1002
+ {
1003
+ description:
1004
+ 'Return the Nowline DSL reference (condensed cheatsheet or full man page). Callable alternative to the nowline://reference resource.',
1005
+ inputSchema: z.object({
1006
+ format: z
1007
+ .enum(['condensed', 'full'])
1008
+ .optional()
1009
+ .describe('Reference format. Defaults to condensed.'),
1010
+ }),
1011
+ outputSchema: ReferenceOutputSchema,
1012
+ annotations: { readOnlyHint: true, idempotentHint: true },
1013
+ },
1014
+ async (args) => {
1015
+ const format = args.format ?? 'condensed';
1016
+ const text = format === 'full' ? REFERENCE_MAN_PAGE : REFERENCE_CHEATSHEET;
1017
+ const structured = { format, text };
1018
+ return {
1019
+ content: [{ type: 'text', text }],
1020
+ structuredContent: structured,
1021
+ };
1022
+ },
1023
+ );
1024
+
1025
+ server.registerTool(
1026
+ 'examples',
1027
+ {
1028
+ description:
1029
+ 'Return canonical .nowline example sources. Callable alternative to the nowline://examples resource.',
1030
+ inputSchema: z.object({
1031
+ name: z
1032
+ .string()
1033
+ .optional()
1034
+ .describe('Example name. Omit for the catalog plus minimal inline.'),
1035
+ }),
1036
+ outputSchema: ExamplesOutputSchema,
1037
+ annotations: { readOnlyHint: true, idempotentHint: true },
1038
+ },
1039
+ async (args) => {
1040
+ const exampleNames = EXAMPLES.map((e) => exampleShortName(e.name));
1041
+ if (args.name) {
1042
+ const ex = findExample(args.name);
1043
+ if (!ex) {
1044
+ return {
1045
+ content: [
1046
+ {
1047
+ type: 'text',
1048
+ text: JSON.stringify({
1049
+ error: `Unknown example "${args.name}".`,
1050
+ names: exampleNames,
1051
+ }),
1052
+ },
1053
+ ],
1054
+ isError: true,
1055
+ };
1056
+ }
1057
+ const structured = { name: exampleShortName(ex.name), source: ex.content };
1058
+ return {
1059
+ content: [{ type: 'text', text: ex.content }],
1060
+ structuredContent: structured,
1061
+ };
1062
+ }
1063
+ const minimal = findExample('minimal') ?? EXAMPLES[0];
1064
+ const structured = {
1065
+ names: exampleNames,
1066
+ name: exampleShortName(minimal.name),
1067
+ source: minimal.content,
1068
+ };
555
1069
  return {
556
1070
  content: [
557
1071
  {
558
1072
  type: 'text',
559
- text: new TextDecoder('utf-8').decode(bytes),
1073
+ text: `# Examples\n\n${exampleNames.map((n) => `- ${n}`).join('\n')}\n\n## ${structured.name}\n\n${minimal.content}`,
560
1074
  },
561
1075
  ],
1076
+ structuredContent: structured,
1077
+ };
1078
+ },
1079
+ );
1080
+
1081
+ server.registerTool(
1082
+ 'schema',
1083
+ {
1084
+ description:
1085
+ 'Return the structured Nowline DSL key vocabulary (directive keys, entity types, item properties).',
1086
+ inputSchema: z.object({}),
1087
+ outputSchema: SchemaOutputSchema,
1088
+ annotations: { readOnlyHint: true, idempotentHint: true },
1089
+ },
1090
+ async () => {
1091
+ const structured = {
1092
+ directiveKeys: [...SCHEMA_VOCABULARY.directiveKeys],
1093
+ entityTypes: [...SCHEMA_VOCABULARY.entityTypes],
1094
+ itemPropertyKeys: [...SCHEMA_VOCABULARY.itemPropertyKeys],
1095
+ };
1096
+ return {
1097
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1098
+ structuredContent: structured,
562
1099
  };
563
1100
  },
564
1101
  );
@@ -568,6 +1105,55 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
568
1105
 
569
1106
  // ---- Helpers ----------------------------------------------------------------
570
1107
 
1108
+ type ContentBlock =
1109
+ | { type: 'text'; text: string; mimeType?: string }
1110
+ | { type: 'image'; data: string; mimeType: string };
1111
+
1112
+ async function buildReviewContentBlocks(
1113
+ source: string,
1114
+ format: ExportFormat,
1115
+ artifactBytes: Uint8Array,
1116
+ inputs: RenderInputs,
1117
+ host: HostEnv,
1118
+ ): Promise<ContentBlock[]> {
1119
+ const blocks: ContentBlock[] = [
1120
+ {
1121
+ type: 'text',
1122
+ text:
1123
+ 'Review this raster for layout issues (truncated labels, crowded lanes, ' +
1124
+ 'now-line position) before finalizing.',
1125
+ },
1126
+ ];
1127
+
1128
+ const artifactWidth = inputs.width ?? DEFAULT_RENDER_WIDTH;
1129
+ let inspectionBytes: Uint8Array;
1130
+
1131
+ if (format === 'png' && artifactWidth <= REVIEW_MAX_WIDTH) {
1132
+ inspectionBytes = artifactBytes;
1133
+ } else {
1134
+ const reviewInputs: RenderInputs = {
1135
+ ...inputs,
1136
+ width: Math.min(artifactWidth, REVIEW_MAX_WIDTH),
1137
+ pngScale: 1,
1138
+ };
1139
+ if (!reviewInputs.fonts) {
1140
+ const fonts = await resolveFonts({ headless: true });
1141
+ reviewInputs.fonts = { sans: fonts.sans, mono: fonts.mono };
1142
+ }
1143
+ inspectionBytes = await exportDocument(source, 'png', reviewInputs, host);
1144
+ }
1145
+
1146
+ if (format !== 'png' || inspectionBytes.byteLength !== artifactBytes.byteLength) {
1147
+ blocks.push({
1148
+ type: 'image',
1149
+ data: Buffer.from(inspectionBytes).toString('base64'),
1150
+ mimeType: 'image/png',
1151
+ });
1152
+ }
1153
+
1154
+ return blocks;
1155
+ }
1156
+
571
1157
  async function listNowlineFiles(dir: string, recursive: boolean): Promise<string[]> {
572
1158
  const results: string[] = [];
573
1159
  try {