@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/src/server.ts CHANGED
@@ -8,14 +8,14 @@
8
8
 
9
9
  import { promises as fs } from 'node:fs';
10
10
  import * as path from 'node:path';
11
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
11
  import {
13
- collectDocumentDiagnostics,
14
- createNowlineServices,
15
- type NowlineFile,
16
- parseNowlineJson,
17
- printNowlineFile,
18
- } from '@nowline/core';
12
+ getUiCapability,
13
+ RESOURCE_MIME_TYPE,
14
+ registerAppResource,
15
+ registerAppTool,
16
+ } from '@modelcontextprotocol/ext-apps/server';
17
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
+ import { parseNowlineJson, printNowlineFile } from '@nowline/core';
19
19
  import {
20
20
  type ExportFormat,
21
21
  exportDocument,
@@ -24,52 +24,78 @@ import {
24
24
  } from '@nowline/export';
25
25
  import { resolveFonts } from '@nowline/export-core';
26
26
  import { buildShareLink } from '@nowline/share-link';
27
- import { URI } from 'langium';
28
27
  import { z } from 'zod';
28
+ import { NOWLINE_MCP_ICONS } from './branding.js';
29
29
  import { CAPABILITIES } from './capabilities.js';
30
- import { CONVERSIONS_GUIDE, EXAMPLES, REFERENCE_MAN_PAGE } from './generated/resources.js';
31
- import { UI_BUNDLE } from './generated/ui-bundle.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';
32
47
  import { registerPrompts } from './prompts.js';
48
+ import { REFERENCE_CHEATSHEET } from './reference-cheatsheet.js';
49
+ import { SCHEMA_VOCABULARY } from './schema-vocab.js';
33
50
  import {
34
51
  CapabilitiesOutputSchema,
35
52
  ConvertOutputSchema,
36
53
  CreateOutputSchema,
37
54
  DeleteOutputSchema,
55
+ ExamplesOutputSchema,
38
56
  ExportOutputSchema,
39
57
  ListItemsOutputSchema,
40
58
  ListOutputSchema,
41
59
  ReadOutputSchema,
60
+ ReferenceOutputSchema,
42
61
  RenderOutputSchema,
62
+ SchemaOutputSchema,
43
63
  UpdateOutputSchema,
44
64
  ValidateOutputSchema,
45
65
  } from './schemas.js';
46
66
 
47
67
  // ---- MCP Apps UI (in-chat live preview) -------------------------------------
48
68
  //
49
- // The MCP Apps extension (SEP-1865) lets a tool return an interactive HTML
50
- // resource the host renders in a sandboxed iframe. We use the embedded-resource
51
- // form: `render` returns a self-contained text/html resource that inlines the
52
- // browser preview bundle (UI_BUNDLE, the @nowline/browser + @nowline/preview-
53
- // shell pipeline) plus the injected source. It is emitted only when the client
54
- // advertises the UI extension capability or the caller passes `preview: true`,
55
- // so plain stdio operation is unchanged and non-UI hosts still receive the
56
- // SVG/PNG content block alongside it (graceful degradation).
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).
57
75
 
58
76
  /** SEP-1865 UI extension capability id; also probed under common short keys. */
59
77
  const MCP_APPS_UI_CAPABILITY = 'io.modelcontextprotocol/ui';
60
- const PREVIEW_UI_URI = 'ui://nowline/preview';
61
- /** SEP-1865 mandates the text/html;profile=mcp-app media type for UI resources. */
62
- const PREVIEW_UI_MIME = 'text/html;profile=mcp-app';
78
+ /** Versioned URI doubles as a cache key — bump suffix on bundle changes. */
79
+ export const PREVIEW_UI_URI = 'ui://nowline/preview-v1';
63
80
 
64
81
  function clientSupportsAppsUi(server: McpServer): boolean {
65
- // Extension capabilities are negotiated under `experimental` in SDK 1.29
66
- // (it does not yet model SEP-1724 extensions as a first-class field), so
67
- // probe the canonical id plus the short `ui` / `apps` aliases some hosts use.
68
- const experimental = server.server.getClientCapabilities()?.experimental as
69
- | Record<string, unknown>
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
+ }
70
88
  | undefined;
71
- if (!experimental) return false;
72
- return Boolean(experimental[MCP_APPS_UI_CAPABILITY] || experimental.ui || experimental.apps);
89
+ if (!caps) return false;
90
+ if (getUiCapability(caps as Parameters<typeof getUiCapability>[0])) return true;
91
+
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
+ );
73
99
  }
74
100
 
75
101
  interface PreviewPayload {
@@ -78,112 +104,23 @@ interface PreviewPayload {
78
104
  now?: string;
79
105
  width?: number;
80
106
  locale?: string;
81
- showLinks?: boolean;
82
- }
83
-
84
- function buildPreviewHtml(payload: PreviewPayload): string {
85
- // The payload (including the .nowline source) is injected as a JSON
86
- // <script> block rather than interpolated into executable JS, so source
87
- // text with quotes/backticks can't break out. Escaping `<` as \u003c keeps
88
- // any embedded "</script>" from closing the block early; JSON.parse in the
89
- // bundle decodes it back. The bundle injects its own stylesheet at runtime,
90
- // so only the root-element sizing CSS is inlined here.
91
- const data = JSON.stringify(payload).replace(/</g, '\\u003c');
92
- return [
93
- '<!doctype html>',
94
- '<html lang="en">',
95
- '<head>',
96
- '<meta charset="utf-8" />',
97
- '<meta name="viewport" content="width=device-width, initial-scale=1" />',
98
- '<title>Nowline preview</title>',
99
- '<style>html,body,#nl-preview-root{margin:0;padding:0;height:100%;width:100%;overflow:hidden;}</style>',
100
- '</head>',
101
- '<body>',
102
- '<div id="nl-preview-root"></div>',
103
- `<script id="nl-preview-data" type="application/json">${data}</script>`,
104
- `<script>${UI_BUNDLE}</script>`,
105
- '</body>',
106
- '</html>',
107
- '',
108
- ].join('\n');
109
107
  }
110
108
 
111
- function previewResourceBlock(payload: PreviewPayload) {
109
+ function leanPreviewBlock(payload: PreviewPayload) {
112
110
  return {
113
- type: 'resource' as const,
114
- resource: {
115
- uri: PREVIEW_UI_URI,
116
- mimeType: PREVIEW_UI_MIME,
117
- text: buildPreviewHtml(payload),
118
- },
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
+ }),
119
120
  };
120
121
  }
121
122
 
122
- // ---- Diagnostic helpers -----------------------------------------------------
123
-
124
- interface McpDiagnostic {
125
- file: string;
126
- line: number;
127
- column: number;
128
- severity: 'error' | 'warning';
129
- code: string;
130
- message: string;
131
- }
132
-
133
- function collectMcpDiagnostics(
134
- doc: Awaited<ReturnType<typeof buildDocument>>,
135
- filePath: string,
136
- ): McpDiagnostic[] {
137
- const raw = collectDocumentDiagnostics(doc);
138
- const out: McpDiagnostic[] = [];
139
- for (const d of raw) {
140
- if (d.origin === 'lexer' || d.origin === 'parser') {
141
- out.push({
142
- file: filePath,
143
- line: 1,
144
- column: 1,
145
- severity: 'error',
146
- code: d.origin === 'lexer' ? 'lexing-error' : 'parsing-error',
147
- message: d.error.message,
148
- });
149
- } else {
150
- const diag = d.diagnostic;
151
- const range = diag.range;
152
- out.push({
153
- file: filePath,
154
- line: (range?.start.line ?? 0) + 1,
155
- column: (range?.start.character ?? 0) + 1,
156
- severity: diag.severity === 1 ? 'error' : 'warning',
157
- code: String(diag.code ?? 'unknown'),
158
- message: diag.message,
159
- });
160
- }
161
- }
162
- return out;
163
- }
164
-
165
- // ---- Langium services -------------------------------------------------------
166
-
167
- let cachedServices: ReturnType<typeof createNowlineServices> | undefined;
168
- let docCounter = 0;
169
-
170
- function getServices() {
171
- if (!cachedServices) cachedServices = createNowlineServices();
172
- return cachedServices;
173
- }
174
-
175
- async function buildDocument(source: string) {
176
- const services = getServices();
177
- const uri = URI.parse(`memory:///mcp-${++docCounter}.nowline`);
178
- const doc = services.shared.workspace.LangiumDocumentFactory.fromString<NowlineFile>(
179
- source,
180
- uri,
181
- );
182
- await services.shared.workspace.DocumentBuilder.build([doc], { validation: true });
183
- return doc;
184
- }
185
-
186
- // ---- Allowed-root enforcement -----------------------------------------------
123
+ // ---- Server factory ---------------------------------------------------------
187
124
 
188
125
  function resolveAndGuard(filePath: string, allowedRoot: string): string {
189
126
  const abs = path.resolve(allowedRoot, filePath);
@@ -229,6 +166,15 @@ function todayUtc(): Date {
229
166
  return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
230
167
  }
231
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
+
232
178
  async function sourceAndPath(
233
179
  args: { source?: string; path?: string },
234
180
  allowedRoot: string,
@@ -257,10 +203,22 @@ export interface McpServerOptions {
257
203
 
258
204
  export function createMcpServer(opts: McpServerOptions = {}): McpServer {
259
205
  const allowedRoot = opts.allowedRoot ?? process.cwd();
260
- const server = new McpServer({
261
- name: opts.name ?? 'nowline',
262
- version: opts.version ?? '0.6.0',
263
- });
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
+ );
264
222
 
265
223
  // ---- Resources ----------------------------------------------------------
266
224
 
@@ -314,6 +272,25 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
314
272
  }),
315
273
  );
316
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
+
317
294
  // ---- Prompts ------------------------------------------------------------
318
295
 
319
296
  registerPrompts(server);
@@ -323,8 +300,9 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
323
300
  server.registerTool(
324
301
  'validate',
325
302
  {
326
- description:
327
- '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
+ ),
328
306
  inputSchema: z.object({
329
307
  source: z.string().optional().describe('Inline .nowline source text to validate.'),
330
308
  path: z
@@ -340,9 +318,24 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
340
318
  const doc = await buildDocument(source);
341
319
  const diagnostics = collectMcpDiagnostics(doc, filePath);
342
320
  const ok = diagnostics.every((d) => d.severity !== 'error');
343
- const structured = { ok, diagnostics };
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 } : {}) };
344
332
  return {
345
- content: [{ type: 'text', text: JSON.stringify(structured, 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
+ ],
346
339
  structuredContent: structured,
347
340
  };
348
341
  },
@@ -378,8 +371,9 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
378
371
  server.registerTool(
379
372
  'create',
380
373
  {
381
- description:
374
+ description: toolDescriptionWithSyntax(
382
375
  'Write a new .nowline file after validation. Overwrites if the path already exists.',
376
+ ),
383
377
  inputSchema: z.object({
384
378
  path: z.string().describe('Absolute or relative path to write the .nowline file.'),
385
379
  source: z.string().describe('The .nowline source text to write.'),
@@ -390,20 +384,8 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
390
384
  },
391
385
  async (args) => {
392
386
  const abs = resolveAndGuard(args.path, allowedRoot);
393
- const doc = await buildDocument(args.source);
394
- const diagnostics = collectMcpDiagnostics(doc, abs);
395
- const errors = diagnostics.filter((d) => d.severity === 'error');
396
- if (errors.length > 0) {
397
- return {
398
- content: [
399
- {
400
- type: 'text',
401
- text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
402
- },
403
- ],
404
- isError: true,
405
- };
406
- }
387
+ const blocked = await diagnosticsErrorBlock(args.source, abs);
388
+ if (!blocked.ok) return blocked.response;
407
389
  await fs.mkdir(path.dirname(abs), { recursive: true });
408
390
  await fs.writeFile(abs, args.source, 'utf-8');
409
391
  const structured = { ok: true, path: abs };
@@ -419,7 +401,9 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
419
401
  server.registerTool(
420
402
  'update',
421
403
  {
422
- description: 'Replace an existing .nowline file after validation.',
404
+ description: toolDescriptionWithSyntax(
405
+ 'Replace an existing .nowline file after validation.',
406
+ ),
423
407
  inputSchema: z.object({
424
408
  path: z
425
409
  .string()
@@ -431,20 +415,8 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
431
415
  },
432
416
  async (args) => {
433
417
  const abs = resolveAndGuard(args.path, allowedRoot);
434
- const doc = await buildDocument(args.source);
435
- const diagnostics = collectMcpDiagnostics(doc, abs);
436
- const errors = diagnostics.filter((d) => d.severity === 'error');
437
- if (errors.length > 0) {
438
- return {
439
- content: [
440
- {
441
- type: 'text',
442
- text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
443
- },
444
- ],
445
- isError: true,
446
- };
447
- }
418
+ const blocked = await diagnosticsErrorBlock(args.source, abs);
419
+ if (!blocked.ok) return blocked.response;
448
420
  await fs.writeFile(abs, args.source, 'utf-8');
449
421
  const structured = { ok: true, path: abs };
450
422
  return {
@@ -514,14 +486,24 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
514
486
 
515
487
  // ---- render -------------------------------------------------------------
516
488
 
517
- server.registerTool(
489
+ registerAppTool(
490
+ server,
518
491
  'render',
519
492
  {
520
- description:
521
- '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
+ ),
522
497
  inputSchema: z.object({
523
498
  source: z.string().optional().describe('Inline .nowline source text.'),
524
- 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
+ ),
525
507
  format: z
526
508
  .enum(['svg', 'png'])
527
509
  .optional()
@@ -542,25 +524,42 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
542
524
  output: z
543
525
  .string()
544
526
  .optional()
545
- .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
+ ),
546
532
  share: z
547
533
  .boolean()
548
534
  .optional()
549
535
  .describe(
550
536
  'When true, include a shareUrl pointing to https://free.nowline.io/open.',
551
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
+ ),
552
544
  preview: z
553
545
  .boolean()
554
546
  .optional()
555
547
  .describe(
556
- 'When true, also return an interactive in-chat HTML preview (MCP Apps UI). Auto-enabled when the client advertises MCP Apps UI support.',
548
+ 'When true, force the in-chat MCP Apps preview. On MCP Apps hosts the preview auto-renders via _meta.ui without this flag.',
557
549
  ),
558
550
  }),
559
551
  outputSchema: RenderOutputSchema,
560
552
  annotations: { readOnlyHint: true, idempotentHint: true },
553
+ _meta: {
554
+ ui: { resourceUri: PREVIEW_UI_URI },
555
+ 'openai/outputTemplate': PREVIEW_UI_URI,
556
+ },
561
557
  },
562
558
  async (args) => {
563
559
  const { source, filePath } = await sourceAndPath(args, allowedRoot);
560
+ const blocked = await diagnosticsErrorBlock(source, filePath);
561
+ if (!blocked.ok) return blocked.response;
562
+
564
563
  const format: ExportFormat = args.format ?? 'svg';
565
564
  const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
566
565
  const inputs: RenderInputs = {
@@ -571,48 +570,90 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
571
570
  width: args.width,
572
571
  pngScale: args.scale,
573
572
  };
574
- 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)) {
575
579
  const result = await resolveFonts({ headless: true });
576
580
  inputs.fonts = { sans: result.sans, mono: result.mono };
577
581
  }
578
- const host = createNodeHostEnv(filePath);
579
- const bytes = await exportDocument(source, format, inputs, host);
582
+ const bytes = needsRender
583
+ ? await exportDocument(source, format, inputs, host)
584
+ : new Uint8Array(0);
580
585
  const shareUrl = args.share
581
586
  ? (buildShareLink({ source, share: true }) ?? undefined)
582
587
  : undefined;
583
588
 
584
- // Optional MCP Apps in-chat preview. Emitted alongside the normal
585
- // content so non-UI hosts still get the SVG/PNG; the bundle renders
586
- // the live SVG itself, so the preview is format-agnostic.
587
- const wantPreview = args.preview === true || clientSupportsAppsUi(server);
588
- const previewBlocks = wantPreview
589
- ? [
590
- previewResourceBlock({
591
- source,
592
- theme: args.theme,
593
- now: args.now,
594
- width: args.width,
595
- locale: 'en-US',
596
- }),
597
- ]
598
- : [];
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 } : {};
599
616
 
600
617
  if (args.output) {
601
618
  const outAbs = resolveAndGuard(args.output, allowedRoot);
602
619
  await fs.mkdir(path.dirname(outAbs), { recursive: true });
603
620
  await fs.writeFile(outAbs, bytes);
604
- const structured = { format, path: outAbs, bytes: bytes.byteLength, shareUrl };
621
+ const structured = {
622
+ format,
623
+ path: outAbs,
624
+ bytes: bytes.byteLength,
625
+ shareUrl,
626
+ ...insightsField,
627
+ };
628
+ return {
629
+ content: [
630
+ ...(appActive ? [leanPreviewBlock(previewPayload)] : []),
631
+ { type: 'text' as const, text: JSON.stringify(structured, null, 2) },
632
+ ...insightHintBlocks,
633
+ ...reviewBlocks,
634
+ ],
635
+ structuredContent: structured,
636
+ };
637
+ }
638
+
639
+ if (appActive) {
640
+ const structured = {
641
+ format,
642
+ shareUrl,
643
+ ...insightsField,
644
+ };
605
645
  return {
606
646
  content: [
607
- { type: 'text', text: JSON.stringify(structured, null, 2) },
608
- ...previewBlocks,
647
+ leanPreviewBlock(previewPayload),
648
+ ...insightHintBlocks,
649
+ ...reviewBlocks,
609
650
  ],
610
651
  structuredContent: structured,
611
652
  };
612
653
  }
613
654
 
614
655
  if (format === 'png') {
615
- const structured = { format, bytes: bytes.byteLength, shareUrl };
656
+ const structured = { format, bytes: bytes.byteLength, shareUrl, ...insightsField };
616
657
  return {
617
658
  content: [
618
659
  {
@@ -620,17 +661,19 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
620
661
  data: Buffer.from(bytes).toString('base64'),
621
662
  mimeType: 'image/png',
622
663
  },
623
- ...previewBlocks,
664
+ ...insightHintBlocks,
665
+ ...reviewBlocks,
624
666
  ],
625
667
  structuredContent: structured,
626
668
  };
627
669
  }
628
670
  const svgText = new TextDecoder('utf-8').decode(bytes);
629
- const structured = { format, shareUrl };
671
+ const structured = { format, shareUrl, ...insightsField };
630
672
  return {
631
673
  content: [
632
674
  { type: 'text', text: svgText, mimeType: 'image/svg+xml' },
633
- ...previewBlocks,
675
+ ...insightHintBlocks,
676
+ ...reviewBlocks,
634
677
  ],
635
678
  structuredContent: structured,
636
679
  };
@@ -649,14 +692,26 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
649
692
  'Export a .nowline roadmap to any of the eight canonical formats. Byte-identical to `nowline -f <format>` for the same source and inputs.',
650
693
  inputSchema: z.object({
651
694
  source: z.string().optional().describe('Inline .nowline source text.'),
652
- 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
+ ),
653
703
  format: z
654
704
  .enum(EXPORT_FORMATS)
655
705
  .describe('Export format: pdf, html, mermaid, xlsx, msproj, or png.'),
656
706
  output: z
657
707
  .string()
658
708
  .optional()
659
- .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
+ ),
660
715
  now: z.string().optional().describe('Now-line date as YYYY-MM-DD (UTC).'),
661
716
  theme: z.enum(['light', 'dark', 'grayscale']).optional(),
662
717
  scale: z.number().optional().describe('PNG scale factor.'),
@@ -682,6 +737,9 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
682
737
  },
683
738
  async (args) => {
684
739
  const { source, filePath } = await sourceAndPath(args, allowedRoot);
740
+ const blocked = await diagnosticsErrorBlock(source, filePath);
741
+ if (!blocked.ok) return blocked.response;
742
+
685
743
  const format: ExportFormat = args.format as NonRenderFormat;
686
744
  const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
687
745
  const inputs: RenderInputs = {
@@ -755,7 +813,14 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
755
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.',
756
814
  inputSchema: z.object({
757
815
  source: z.string().optional().describe('Inline source text to convert.'),
758
- path: z.string().optional().describe('Path to the source file.'),
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
+ ),
759
824
  to: z
760
825
  .enum(['json', 'nowline'])
761
826
  .describe(
@@ -930,11 +995,165 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
930
995
  },
931
996
  );
932
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
+ };
1069
+ return {
1070
+ content: [
1071
+ {
1072
+ type: 'text',
1073
+ text: `# Examples\n\n${exampleNames.map((n) => `- ${n}`).join('\n')}\n\n## ${structured.name}\n\n${minimal.content}`,
1074
+ },
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,
1099
+ };
1100
+ },
1101
+ );
1102
+
933
1103
  return server;
934
1104
  }
935
1105
 
936
1106
  // ---- Helpers ----------------------------------------------------------------
937
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
+
938
1157
  async function listNowlineFiles(dir: string, recursive: boolean): Promise<string[]> {
939
1158
  const results: string[] = [];
940
1159
  try {