@nowline/mcp 0.7.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 (44) 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/diagnostics.d.ts +59 -0
  6. package/dist/diagnostics.d.ts.map +1 -0
  7. package/dist/diagnostics.js +117 -0
  8. package/dist/diagnostics.js.map +1 -0
  9. package/dist/generated/ui-bundle.d.ts +2 -0
  10. package/dist/generated/ui-bundle.d.ts.map +1 -1
  11. package/dist/generated/ui-bundle.js +4 -3
  12. package/dist/generated/ui-bundle.js.map +1 -1
  13. package/dist/reference-cheatsheet.d.ts +2 -0
  14. package/dist/reference-cheatsheet.d.ts.map +1 -0
  15. package/dist/reference-cheatsheet.js +47 -0
  16. package/dist/reference-cheatsheet.js.map +1 -0
  17. package/dist/schema-vocab.d.ts +6 -0
  18. package/dist/schema-vocab.d.ts.map +1 -0
  19. package/dist/schema-vocab.js +55 -0
  20. package/dist/schema-vocab.js.map +1 -0
  21. package/dist/schemas.d.ts +52 -0
  22. package/dist/schemas.d.ts.map +1 -1
  23. package/dist/schemas.js +24 -1
  24. package/dist/schemas.js.map +1 -1
  25. package/dist/server.d.ts +2 -0
  26. package/dist/server.d.ts.map +1 -1
  27. package/dist/server.js +312 -165
  28. package/dist/server.js.map +1 -1
  29. package/dist/ui/entry.js +148 -110
  30. package/dist/ui/entry.js.map +1 -1
  31. package/dist/ui/payload.d.ts +30 -0
  32. package/dist/ui/payload.d.ts.map +1 -0
  33. package/dist/ui/payload.js +49 -0
  34. package/dist/ui/payload.js.map +1 -0
  35. package/package.json +11 -7
  36. package/src/branding.ts +26 -0
  37. package/src/diagnostics.ts +185 -0
  38. package/src/generated/ui-bundle.ts +5 -3
  39. package/src/reference-cheatsheet.ts +47 -0
  40. package/src/schema-vocab.ts +55 -0
  41. package/src/schemas.ts +28 -1
  42. package/src/server.ts +419 -200
  43. package/src/ui/entry.ts +167 -122
  44. package/src/ui/payload.ts +63 -0
package/dist/server.js CHANGED
@@ -7,123 +7,59 @@
7
7
  // Spec: specs/mcp.md.
8
8
  import { promises as fs } from 'node:fs';
9
9
  import * as path from 'node:path';
10
+ import { getUiCapability, RESOURCE_MIME_TYPE, registerAppResource, registerAppTool, } from '@modelcontextprotocol/ext-apps/server';
10
11
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
- import { collectDocumentDiagnostics, createNowlineServices, parseNowlineJson, printNowlineFile, } from '@nowline/core';
12
+ import { parseNowlineJson, printNowlineFile } from '@nowline/core';
12
13
  import { exportDocument, } from '@nowline/export';
13
14
  import { resolveFonts } from '@nowline/export-core';
14
15
  import { buildShareLink } from '@nowline/share-link';
15
- import { URI } from 'langium';
16
16
  import { z } from 'zod';
17
+ import { NOWLINE_MCP_ICONS } from './branding.js';
17
18
  import { CAPABILITIES } from './capabilities.js';
18
- import { CONVERSIONS_GUIDE, EXAMPLES, REFERENCE_MAN_PAGE } from './generated/resources.js';
19
- import { UI_BUNDLE } from './generated/ui-bundle.js';
19
+ import { buildDocument, collectMcpDiagnostics, collectMcpLayoutInsights, DEFAULT_RENDER_WIDTH, diagnosticsErrorBlock, LAYOUT_INSIGHT_HINT, REVIEW_MAX_WIDTH, toolDescriptionWithSyntax, } from './diagnostics.js';
20
+ import { CONVERSIONS_GUIDE, EXAMPLES, REFERENCE_MAN_PAGE, } from './generated/resources.js';
21
+ import { PREVIEW_HTML } from './generated/ui-bundle.js';
20
22
  import { registerPrompts } from './prompts.js';
21
- import { CapabilitiesOutputSchema, ConvertOutputSchema, CreateOutputSchema, DeleteOutputSchema, ExportOutputSchema, ListItemsOutputSchema, ListOutputSchema, ReadOutputSchema, RenderOutputSchema, UpdateOutputSchema, ValidateOutputSchema, } from './schemas.js';
23
+ import { REFERENCE_CHEATSHEET } from './reference-cheatsheet.js';
24
+ import { SCHEMA_VOCABULARY } from './schema-vocab.js';
25
+ import { CapabilitiesOutputSchema, ConvertOutputSchema, CreateOutputSchema, DeleteOutputSchema, ExamplesOutputSchema, ExportOutputSchema, ListItemsOutputSchema, ListOutputSchema, ReadOutputSchema, ReferenceOutputSchema, RenderOutputSchema, SchemaOutputSchema, UpdateOutputSchema, ValidateOutputSchema, } from './schemas.js';
22
26
  // ---- MCP Apps UI (in-chat live preview) -------------------------------------
23
27
  //
24
- // The MCP Apps extension (SEP-1865) lets a tool return an interactive HTML
25
- // resource the host renders in a sandboxed iframe. We use the embedded-resource
26
- // form: `render` returns a self-contained text/html resource that inlines the
27
- // browser preview bundle (UI_BUNDLE, the @nowline/browser + @nowline/preview-
28
- // shell pipeline) plus the injected source. It is emitted only when the client
29
- // advertises the UI extension capability or the caller passes `preview: true`,
30
- // so plain stdio operation is unchanged and non-UI hosts still receive the
31
- // SVG/PNG content block alongside it (graceful degradation).
28
+ // Official MCP Apps model (SEP-1865): a pre-declared ui:// resource serves
29
+ // static HTML; the render tool declares _meta.ui.resourceUri; per-call data
30
+ // flows through the ontoolresult handshake. When an MCP Apps host is active,
31
+ // render returns a lean nowline.preview JSON payload (no inline SVG/PNG) so
32
+ // results stay under the host's ~150K inline cap. Non-apps hosts still get
33
+ // the full SVG/PNG inline (graceful degradation).
32
34
  /** SEP-1865 UI extension capability id; also probed under common short keys. */
33
35
  const MCP_APPS_UI_CAPABILITY = 'io.modelcontextprotocol/ui';
34
- const PREVIEW_UI_URI = 'ui://nowline/preview';
35
- /** SEP-1865 mandates the text/html;profile=mcp-app media type for UI resources. */
36
- const PREVIEW_UI_MIME = 'text/html;profile=mcp-app';
36
+ /** Versioned URI doubles as a cache key — bump suffix on bundle changes. */
37
+ export const PREVIEW_UI_URI = 'ui://nowline/preview-v1';
37
38
  function clientSupportsAppsUi(server) {
38
- // Extension capabilities are negotiated under `experimental` in SDK 1.29
39
- // (it does not yet model SEP-1724 extensions as a first-class field), so
40
- // probe the canonical id plus the short `ui` / `apps` aliases some hosts use.
41
- const experimental = server.server.getClientCapabilities()?.experimental;
42
- if (!experimental)
39
+ // SEP-1724 negotiated extensions: canonical id under `extensions`.
40
+ const caps = server.server.getClientCapabilities();
41
+ if (!caps)
43
42
  return false;
44
- return Boolean(experimental[MCP_APPS_UI_CAPABILITY] || experimental.ui || experimental.apps);
43
+ if (getUiCapability(caps))
44
+ return true;
45
+ // Hosts also advertise under `experimental` or short `ui` / `apps` aliases.
46
+ const buckets = [caps.extensions, caps.experimental].filter((bucket) => Boolean(bucket));
47
+ return buckets.some((bucket) => Boolean(bucket[MCP_APPS_UI_CAPABILITY] || bucket.ui || bucket.apps));
45
48
  }
46
- function buildPreviewHtml(payload) {
47
- // The payload (including the .nowline source) is injected as a JSON
48
- // <script> block rather than interpolated into executable JS, so source
49
- // text with quotes/backticks can't break out. Escaping `<` as \u003c keeps
50
- // any embedded "</script>" from closing the block early; JSON.parse in the
51
- // bundle decodes it back. The bundle injects its own stylesheet at runtime,
52
- // so only the root-element sizing CSS is inlined here.
53
- const data = JSON.stringify(payload).replace(/</g, '\\u003c');
54
- return [
55
- '<!doctype html>',
56
- '<html lang="en">',
57
- '<head>',
58
- '<meta charset="utf-8" />',
59
- '<meta name="viewport" content="width=device-width, initial-scale=1" />',
60
- '<title>Nowline preview</title>',
61
- '<style>html,body,#nl-preview-root{margin:0;padding:0;height:100%;width:100%;overflow:hidden;}</style>',
62
- '</head>',
63
- '<body>',
64
- '<div id="nl-preview-root"></div>',
65
- `<script id="nl-preview-data" type="application/json">${data}</script>`,
66
- `<script>${UI_BUNDLE}</script>`,
67
- '</body>',
68
- '</html>',
69
- '',
70
- ].join('\n');
71
- }
72
- function previewResourceBlock(payload) {
49
+ function leanPreviewBlock(payload) {
73
50
  return {
74
- type: 'resource',
75
- resource: {
76
- uri: PREVIEW_UI_URI,
77
- mimeType: PREVIEW_UI_MIME,
78
- text: buildPreviewHtml(payload),
79
- },
51
+ type: 'text',
52
+ text: JSON.stringify({
53
+ kind: 'nowline.preview',
54
+ source: payload.source,
55
+ theme: payload.theme,
56
+ now: payload.now,
57
+ width: payload.width,
58
+ locale: payload.locale,
59
+ }),
80
60
  };
81
61
  }
82
- function collectMcpDiagnostics(doc, filePath) {
83
- const raw = collectDocumentDiagnostics(doc);
84
- const out = [];
85
- for (const d of raw) {
86
- if (d.origin === 'lexer' || d.origin === 'parser') {
87
- out.push({
88
- file: filePath,
89
- line: 1,
90
- column: 1,
91
- severity: 'error',
92
- code: d.origin === 'lexer' ? 'lexing-error' : 'parsing-error',
93
- message: d.error.message,
94
- });
95
- }
96
- else {
97
- const diag = d.diagnostic;
98
- const range = diag.range;
99
- out.push({
100
- file: filePath,
101
- line: (range?.start.line ?? 0) + 1,
102
- column: (range?.start.character ?? 0) + 1,
103
- severity: diag.severity === 1 ? 'error' : 'warning',
104
- code: String(diag.code ?? 'unknown'),
105
- message: diag.message,
106
- });
107
- }
108
- }
109
- return out;
110
- }
111
- // ---- Langium services -------------------------------------------------------
112
- let cachedServices;
113
- let docCounter = 0;
114
- function getServices() {
115
- if (!cachedServices)
116
- cachedServices = createNowlineServices();
117
- return cachedServices;
118
- }
119
- async function buildDocument(source) {
120
- const services = getServices();
121
- const uri = URI.parse(`memory:///mcp-${++docCounter}.nowline`);
122
- const doc = services.shared.workspace.LangiumDocumentFactory.fromString(source, uri);
123
- await services.shared.workspace.DocumentBuilder.build([doc], { validation: true });
124
- return doc;
125
- }
126
- // ---- Allowed-root enforcement -----------------------------------------------
62
+ // ---- Server factory ---------------------------------------------------------
127
63
  function resolveAndGuard(filePath, allowedRoot) {
128
64
  const abs = path.resolve(allowedRoot, filePath);
129
65
  const guard = path.resolve(allowedRoot);
@@ -163,6 +99,13 @@ function todayUtc() {
163
99
  const now = new Date();
164
100
  return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
165
101
  }
102
+ function exampleShortName(fullName) {
103
+ return fullName.endsWith('.nowline') ? fullName.slice(0, -'.nowline'.length) : fullName;
104
+ }
105
+ function findExample(name) {
106
+ const withExt = name.endsWith('.nowline') ? name : `${name}.nowline`;
107
+ return EXAMPLES.find((e) => e.name === withExt || e.name === name);
108
+ }
166
109
  async function sourceAndPath(args, allowedRoot) {
167
110
  if (args.source !== undefined && args.path === undefined) {
168
111
  return { source: args.source, filePath: path.join(allowedRoot, 'unnamed.nowline') };
@@ -179,6 +122,14 @@ export function createMcpServer(opts = {}) {
179
122
  const server = new McpServer({
180
123
  name: opts.name ?? 'nowline',
181
124
  version: opts.version ?? '0.6.0',
125
+ icons: [...NOWLINE_MCP_ICONS],
126
+ }, {
127
+ instructions: 'Nowline manages roadmaps written in the .nowline plain-text DSL — NOT JSON or any other ' +
128
+ 'structured format. All `source` parameters expect `.nowline` DSL text (starts with `nowline v1`). ' +
129
+ 'Workflow: 1. call `reference` or `examples` to learn syntax → 2. write `.nowline` → ' +
130
+ '3. call `render` (validates + renders; or `validate` alone) → 4. fix errors keyed on `NL.E####` ' +
131
+ 'and re-render → 5. review returned layout `insights` (what reflowed) → 6. when uncertain, ' +
132
+ 'call `render` with `review:true` for a final visual check. JSON in `convert` is AST conversion only.',
182
133
  });
183
134
  // ---- Resources ----------------------------------------------------------
184
135
  server.registerResource('nowline-reference', 'nowline://reference', {
@@ -211,11 +162,22 @@ export function createMcpServer(opts = {}) {
211
162
  },
212
163
  ],
213
164
  }));
165
+ registerAppResource(server, 'nowline-preview', PREVIEW_UI_URI, {
166
+ description: 'Interactive in-chat roadmap preview (MCP Apps). Hydrates via ontoolresult.',
167
+ }, async () => ({
168
+ contents: [
169
+ {
170
+ uri: PREVIEW_UI_URI,
171
+ mimeType: RESOURCE_MIME_TYPE,
172
+ text: PREVIEW_HTML,
173
+ },
174
+ ],
175
+ }));
214
176
  // ---- Prompts ------------------------------------------------------------
215
177
  registerPrompts(server);
216
178
  // ---- validate -----------------------------------------------------------
217
179
  server.registerTool('validate', {
218
- description: 'Parse and validate a .nowline roadmap. Returns ok=true and an empty diagnostics array if valid, or ok=false with structured diagnostics.',
180
+ description: toolDescriptionWithSyntax('Parse and validate a .nowline roadmap. Returns ok=true with optional layout insights when valid, or ok=false with structured diagnostics.'),
219
181
  inputSchema: z.object({
220
182
  source: z.string().optional().describe('Inline .nowline source text to validate.'),
221
183
  path: z
@@ -230,9 +192,24 @@ export function createMcpServer(opts = {}) {
230
192
  const doc = await buildDocument(source);
231
193
  const diagnostics = collectMcpDiagnostics(doc, filePath);
232
194
  const ok = diagnostics.every((d) => d.severity !== 'error');
233
- const structured = { ok, diagnostics };
195
+ const insights = ok
196
+ ? await collectMcpLayoutInsights({
197
+ source,
198
+ filePath,
199
+ today: todayUtc(),
200
+ locale: 'en-US',
201
+ readFile: createNodeHostEnv(filePath).readSource,
202
+ doc,
203
+ })
204
+ : [];
205
+ const structured = { ok, diagnostics, ...(insights.length > 0 ? { insights } : {}) };
234
206
  return {
235
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
207
+ content: [
208
+ { type: 'text', text: JSON.stringify(structured, null, 2) },
209
+ ...(insights.length > 0
210
+ ? [{ type: 'text', text: LAYOUT_INSIGHT_HINT }]
211
+ : []),
212
+ ],
236
213
  structuredContent: structured,
237
214
  };
238
215
  });
@@ -257,7 +234,7 @@ export function createMcpServer(opts = {}) {
257
234
  });
258
235
  // ---- create -------------------------------------------------------------
259
236
  server.registerTool('create', {
260
- description: 'Write a new .nowline file after validation. Overwrites if the path already exists.',
237
+ description: toolDescriptionWithSyntax('Write a new .nowline file after validation. Overwrites if the path already exists.'),
261
238
  inputSchema: z.object({
262
239
  path: z.string().describe('Absolute or relative path to write the .nowline file.'),
263
240
  source: z.string().describe('The .nowline source text to write.'),
@@ -267,20 +244,9 @@ export function createMcpServer(opts = {}) {
267
244
  annotations: { destructiveHint: true, idempotentHint: true },
268
245
  }, async (args) => {
269
246
  const abs = resolveAndGuard(args.path, allowedRoot);
270
- const doc = await buildDocument(args.source);
271
- const diagnostics = collectMcpDiagnostics(doc, abs);
272
- const errors = diagnostics.filter((d) => d.severity === 'error');
273
- if (errors.length > 0) {
274
- return {
275
- content: [
276
- {
277
- type: 'text',
278
- text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
279
- },
280
- ],
281
- isError: true,
282
- };
283
- }
247
+ const blocked = await diagnosticsErrorBlock(args.source, abs);
248
+ if (!blocked.ok)
249
+ return blocked.response;
284
250
  await fs.mkdir(path.dirname(abs), { recursive: true });
285
251
  await fs.writeFile(abs, args.source, 'utf-8');
286
252
  const structured = { ok: true, path: abs };
@@ -291,7 +257,7 @@ export function createMcpServer(opts = {}) {
291
257
  });
292
258
  // ---- update -------------------------------------------------------------
293
259
  server.registerTool('update', {
294
- description: 'Replace an existing .nowline file after validation.',
260
+ description: toolDescriptionWithSyntax('Replace an existing .nowline file after validation.'),
295
261
  inputSchema: z.object({
296
262
  path: z
297
263
  .string()
@@ -302,20 +268,9 @@ export function createMcpServer(opts = {}) {
302
268
  annotations: { idempotentHint: true },
303
269
  }, async (args) => {
304
270
  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
- }
271
+ const blocked = await diagnosticsErrorBlock(args.source, abs);
272
+ if (!blocked.ok)
273
+ return blocked.response;
319
274
  await fs.writeFile(abs, args.source, 'utf-8');
320
275
  const structured = { ok: true, path: abs };
321
276
  return {
@@ -368,11 +323,17 @@ export function createMcpServer(opts = {}) {
368
323
  };
369
324
  });
370
325
  // ---- render -------------------------------------------------------------
371
- server.registerTool('render', {
372
- description: '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.',
326
+ registerAppTool(server, 'render', {
327
+ description: toolDescriptionWithSyntax('Validate then render a .nowline roadmap to SVG or PNG (combined validate+render+share). ' +
328
+ 'Returns structured diagnostics on error-severity input instead of a raw kernel error.'),
373
329
  inputSchema: z.object({
374
330
  source: z.string().optional().describe('Inline .nowline source text.'),
375
- path: z.string().optional().describe('Path to the .nowline file.'),
331
+ path: z
332
+ .string()
333
+ .optional()
334
+ .describe('Real local filesystem path to the .nowline file (e.g. /Users/name/Desktop/foo.nowline). ' +
335
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
336
+ 'those do not exist on the host filesystem. Pass `source` instead.'),
376
337
  format: z
377
338
  .enum(['svg', 'png'])
378
339
  .optional()
@@ -393,20 +354,33 @@ export function createMcpServer(opts = {}) {
393
354
  output: z
394
355
  .string()
395
356
  .optional()
396
- .describe('Write output to this path instead of returning inline.'),
357
+ .describe('Real local filesystem path to write the output file (e.g. /Users/name/Desktop/roadmap.svg). ' +
358
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
359
+ 'omit this parameter to receive output inline instead.'),
397
360
  share: z
398
361
  .boolean()
399
362
  .optional()
400
363
  .describe('When true, include a shareUrl pointing to https://free.nowline.io/open.'),
364
+ review: z
365
+ .boolean()
366
+ .optional()
367
+ .describe('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.'),
401
368
  preview: z
402
369
  .boolean()
403
370
  .optional()
404
- .describe('When true, also return an interactive in-chat HTML preview (MCP Apps UI). Auto-enabled when the client advertises MCP Apps UI support.'),
371
+ .describe('When true, force the in-chat MCP Apps preview. On MCP Apps hosts the preview auto-renders via _meta.ui without this flag.'),
405
372
  }),
406
373
  outputSchema: RenderOutputSchema,
407
374
  annotations: { readOnlyHint: true, idempotentHint: true },
375
+ _meta: {
376
+ ui: { resourceUri: PREVIEW_UI_URI },
377
+ 'openai/outputTemplate': PREVIEW_UI_URI,
378
+ },
408
379
  }, async (args) => {
409
380
  const { source, filePath } = await sourceAndPath(args, allowedRoot);
381
+ const blocked = await diagnosticsErrorBlock(source, filePath);
382
+ if (!blocked.ok)
383
+ return blocked.response;
410
384
  const format = args.format ?? 'svg';
411
385
  const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
412
386
  const inputs = {
@@ -417,45 +391,81 @@ export function createMcpServer(opts = {}) {
417
391
  width: args.width,
418
392
  pngScale: args.scale,
419
393
  };
420
- if (format === 'png') {
394
+ const host = createNodeHostEnv(filePath);
395
+ const appActive = args.preview === true || clientSupportsAppsUi(server);
396
+ // Skip the full render when the bytes won't be used: apps host with
397
+ // no write-to-disk path and no review attachment requested.
398
+ const needsRender = !appActive || !!args.output || args.review === true;
399
+ if (needsRender && (format === 'png' || args.review === true)) {
421
400
  const result = await resolveFonts({ headless: true });
422
401
  inputs.fonts = { sans: result.sans, mono: result.mono };
423
402
  }
424
- const host = createNodeHostEnv(filePath);
425
- const bytes = await exportDocument(source, format, inputs, host);
403
+ const bytes = needsRender
404
+ ? await exportDocument(source, format, inputs, host)
405
+ : new Uint8Array(0);
426
406
  const shareUrl = args.share
427
407
  ? (buildShareLink({ source, share: true }) ?? undefined)
428
408
  : undefined;
429
- // Optional MCP Apps in-chat preview. Emitted alongside the normal
430
- // content so non-UI hosts still get the SVG/PNG; the bundle renders
431
- // the live SVG itself, so the preview is format-agnostic.
432
- const wantPreview = args.preview === true || clientSupportsAppsUi(server);
433
- const previewBlocks = wantPreview
434
- ? [
435
- previewResourceBlock({
436
- source,
437
- theme: args.theme,
438
- now: args.now,
439
- width: args.width,
440
- locale: 'en-US',
441
- }),
442
- ]
409
+ const insights = await collectMcpLayoutInsights({
410
+ source,
411
+ filePath,
412
+ today,
413
+ theme: args.theme ?? 'light',
414
+ width: args.width,
415
+ locale: 'en-US',
416
+ readFile: host.readSource,
417
+ doc: blocked.doc,
418
+ });
419
+ const previewPayload = {
420
+ source,
421
+ theme: args.theme,
422
+ now: args.now,
423
+ width: args.width,
424
+ locale: 'en-US',
425
+ };
426
+ const reviewBlocks = args.review === true
427
+ ? await buildReviewContentBlocks(source, format, bytes, inputs, host)
443
428
  : [];
429
+ const insightHintBlocks = insights.length > 0 ? [{ type: 'text', text: LAYOUT_INSIGHT_HINT }] : [];
430
+ const insightsField = insights.length > 0 ? { insights } : {};
444
431
  if (args.output) {
445
432
  const outAbs = resolveAndGuard(args.output, allowedRoot);
446
433
  await fs.mkdir(path.dirname(outAbs), { recursive: true });
447
434
  await fs.writeFile(outAbs, bytes);
448
- const structured = { format, path: outAbs, bytes: bytes.byteLength, shareUrl };
435
+ const structured = {
436
+ format,
437
+ path: outAbs,
438
+ bytes: bytes.byteLength,
439
+ shareUrl,
440
+ ...insightsField,
441
+ };
449
442
  return {
450
443
  content: [
444
+ ...(appActive ? [leanPreviewBlock(previewPayload)] : []),
451
445
  { type: 'text', text: JSON.stringify(structured, null, 2) },
452
- ...previewBlocks,
446
+ ...insightHintBlocks,
447
+ ...reviewBlocks,
448
+ ],
449
+ structuredContent: structured,
450
+ };
451
+ }
452
+ if (appActive) {
453
+ const structured = {
454
+ format,
455
+ shareUrl,
456
+ ...insightsField,
457
+ };
458
+ return {
459
+ content: [
460
+ leanPreviewBlock(previewPayload),
461
+ ...insightHintBlocks,
462
+ ...reviewBlocks,
453
463
  ],
454
464
  structuredContent: structured,
455
465
  };
456
466
  }
457
467
  if (format === 'png') {
458
- const structured = { format, bytes: bytes.byteLength, shareUrl };
468
+ const structured = { format, bytes: bytes.byteLength, shareUrl, ...insightsField };
459
469
  return {
460
470
  content: [
461
471
  {
@@ -463,17 +473,19 @@ export function createMcpServer(opts = {}) {
463
473
  data: Buffer.from(bytes).toString('base64'),
464
474
  mimeType: 'image/png',
465
475
  },
466
- ...previewBlocks,
476
+ ...insightHintBlocks,
477
+ ...reviewBlocks,
467
478
  ],
468
479
  structuredContent: structured,
469
480
  };
470
481
  }
471
482
  const svgText = new TextDecoder('utf-8').decode(bytes);
472
- const structured = { format, shareUrl };
483
+ const structured = { format, shareUrl, ...insightsField };
473
484
  return {
474
485
  content: [
475
486
  { type: 'text', text: svgText, mimeType: 'image/svg+xml' },
476
- ...previewBlocks,
487
+ ...insightHintBlocks,
488
+ ...reviewBlocks,
477
489
  ],
478
490
  structuredContent: structured,
479
491
  };
@@ -484,14 +496,22 @@ export function createMcpServer(opts = {}) {
484
496
  description: 'Export a .nowline roadmap to any of the eight canonical formats. Byte-identical to `nowline -f <format>` for the same source and inputs.',
485
497
  inputSchema: z.object({
486
498
  source: z.string().optional().describe('Inline .nowline source text.'),
487
- path: z.string().optional().describe('Path to the .nowline file.'),
499
+ path: z
500
+ .string()
501
+ .optional()
502
+ .describe('Real local filesystem path to the .nowline file (e.g. /Users/name/Desktop/foo.nowline). ' +
503
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
504
+ 'those do not exist on the host filesystem. Pass `source` instead.'),
488
505
  format: z
489
506
  .enum(EXPORT_FORMATS)
490
507
  .describe('Export format: pdf, html, mermaid, xlsx, msproj, or png.'),
491
508
  output: z
492
509
  .string()
493
510
  .optional()
494
- .describe('Path to write the output file. Required for binary formats.'),
511
+ .describe('Real local filesystem path to write the output (e.g. /Users/name/Desktop/roadmap.pdf). ' +
512
+ 'Required for binary formats (pdf, xlsx, msproj, png). ' +
513
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
514
+ 'those do not exist on the host filesystem.'),
495
515
  now: z.string().optional().describe('Now-line date as YYYY-MM-DD (UTC).'),
496
516
  theme: z.enum(['light', 'dark', 'grayscale']).optional(),
497
517
  scale: z.number().optional().describe('PNG scale factor.'),
@@ -514,6 +534,9 @@ export function createMcpServer(opts = {}) {
514
534
  annotations: { readOnlyHint: true, idempotentHint: true },
515
535
  }, async (args) => {
516
536
  const { source, filePath } = await sourceAndPath(args, allowedRoot);
537
+ const blocked = await diagnosticsErrorBlock(source, filePath);
538
+ if (!blocked.ok)
539
+ return blocked.response;
517
540
  const format = args.format;
518
541
  const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
519
542
  const inputs = {
@@ -578,7 +601,12 @@ export function createMcpServer(opts = {}) {
578
601
  description: '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.',
579
602
  inputSchema: z.object({
580
603
  source: z.string().optional().describe('Inline source text to convert.'),
581
- path: z.string().optional().describe('Path to the source file.'),
604
+ path: z
605
+ .string()
606
+ .optional()
607
+ .describe('Real local filesystem path to the source file. ' +
608
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
609
+ 'pass `source` instead.'),
582
610
  to: z
583
611
  .enum(['json', 'nowline'])
584
612
  .describe('"json" — serialize .nowline text to JSON AST. "nowline" — pretty-print a JSON AST back to .nowline source.'),
@@ -702,9 +730,128 @@ export function createMcpServer(opts = {}) {
702
730
  structuredContent: structured,
703
731
  };
704
732
  });
733
+ // ---- reference / examples / schema (discovery tools) --------------------
734
+ server.registerTool('reference', {
735
+ description: 'Return the Nowline DSL reference (condensed cheatsheet or full man page). Callable alternative to the nowline://reference resource.',
736
+ inputSchema: z.object({
737
+ format: z
738
+ .enum(['condensed', 'full'])
739
+ .optional()
740
+ .describe('Reference format. Defaults to condensed.'),
741
+ }),
742
+ outputSchema: ReferenceOutputSchema,
743
+ annotations: { readOnlyHint: true, idempotentHint: true },
744
+ }, async (args) => {
745
+ const format = args.format ?? 'condensed';
746
+ const text = format === 'full' ? REFERENCE_MAN_PAGE : REFERENCE_CHEATSHEET;
747
+ const structured = { format, text };
748
+ return {
749
+ content: [{ type: 'text', text }],
750
+ structuredContent: structured,
751
+ };
752
+ });
753
+ server.registerTool('examples', {
754
+ description: 'Return canonical .nowline example sources. Callable alternative to the nowline://examples resource.',
755
+ inputSchema: z.object({
756
+ name: z
757
+ .string()
758
+ .optional()
759
+ .describe('Example name. Omit for the catalog plus minimal inline.'),
760
+ }),
761
+ outputSchema: ExamplesOutputSchema,
762
+ annotations: { readOnlyHint: true, idempotentHint: true },
763
+ }, async (args) => {
764
+ const exampleNames = EXAMPLES.map((e) => exampleShortName(e.name));
765
+ if (args.name) {
766
+ const ex = findExample(args.name);
767
+ if (!ex) {
768
+ return {
769
+ content: [
770
+ {
771
+ type: 'text',
772
+ text: JSON.stringify({
773
+ error: `Unknown example "${args.name}".`,
774
+ names: exampleNames,
775
+ }),
776
+ },
777
+ ],
778
+ isError: true,
779
+ };
780
+ }
781
+ const structured = { name: exampleShortName(ex.name), source: ex.content };
782
+ return {
783
+ content: [{ type: 'text', text: ex.content }],
784
+ structuredContent: structured,
785
+ };
786
+ }
787
+ const minimal = findExample('minimal') ?? EXAMPLES[0];
788
+ const structured = {
789
+ names: exampleNames,
790
+ name: exampleShortName(minimal.name),
791
+ source: minimal.content,
792
+ };
793
+ return {
794
+ content: [
795
+ {
796
+ type: 'text',
797
+ text: `# Examples\n\n${exampleNames.map((n) => `- ${n}`).join('\n')}\n\n## ${structured.name}\n\n${minimal.content}`,
798
+ },
799
+ ],
800
+ structuredContent: structured,
801
+ };
802
+ });
803
+ server.registerTool('schema', {
804
+ description: 'Return the structured Nowline DSL key vocabulary (directive keys, entity types, item properties).',
805
+ inputSchema: z.object({}),
806
+ outputSchema: SchemaOutputSchema,
807
+ annotations: { readOnlyHint: true, idempotentHint: true },
808
+ }, async () => {
809
+ const structured = {
810
+ directiveKeys: [...SCHEMA_VOCABULARY.directiveKeys],
811
+ entityTypes: [...SCHEMA_VOCABULARY.entityTypes],
812
+ itemPropertyKeys: [...SCHEMA_VOCABULARY.itemPropertyKeys],
813
+ };
814
+ return {
815
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
816
+ structuredContent: structured,
817
+ };
818
+ });
705
819
  return server;
706
820
  }
707
- // ---- Helpers ----------------------------------------------------------------
821
+ async function buildReviewContentBlocks(source, format, artifactBytes, inputs, host) {
822
+ const blocks = [
823
+ {
824
+ type: 'text',
825
+ text: 'Review this raster for layout issues (truncated labels, crowded lanes, ' +
826
+ 'now-line position) before finalizing.',
827
+ },
828
+ ];
829
+ const artifactWidth = inputs.width ?? DEFAULT_RENDER_WIDTH;
830
+ let inspectionBytes;
831
+ if (format === 'png' && artifactWidth <= REVIEW_MAX_WIDTH) {
832
+ inspectionBytes = artifactBytes;
833
+ }
834
+ else {
835
+ const reviewInputs = {
836
+ ...inputs,
837
+ width: Math.min(artifactWidth, REVIEW_MAX_WIDTH),
838
+ pngScale: 1,
839
+ };
840
+ if (!reviewInputs.fonts) {
841
+ const fonts = await resolveFonts({ headless: true });
842
+ reviewInputs.fonts = { sans: fonts.sans, mono: fonts.mono };
843
+ }
844
+ inspectionBytes = await exportDocument(source, 'png', reviewInputs, host);
845
+ }
846
+ if (format !== 'png' || inspectionBytes.byteLength !== artifactBytes.byteLength) {
847
+ blocks.push({
848
+ type: 'image',
849
+ data: Buffer.from(inspectionBytes).toString('base64'),
850
+ mimeType: 'image/png',
851
+ });
852
+ }
853
+ return blocks;
854
+ }
708
855
  async function listNowlineFiles(dir, recursive) {
709
856
  const results = [];
710
857
  try {