@nowline/mcp 0.8.6 → 0.10.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/capabilities.d.ts.map +1 -1
  2. package/dist/capabilities.js +2 -9
  3. package/dist/capabilities.js.map +1 -1
  4. package/dist/diagnostics.d.ts +43 -15
  5. package/dist/diagnostics.d.ts.map +1 -1
  6. package/dist/diagnostics.js +77 -25
  7. package/dist/diagnostics.js.map +1 -1
  8. package/dist/generated/resources.d.ts.map +1 -1
  9. package/dist/generated/resources.js +15 -3
  10. package/dist/generated/resources.js.map +1 -1
  11. package/dist/generated/ui-bundle.d.ts.map +1 -1
  12. package/dist/generated/ui-bundle.js +2 -2
  13. package/dist/generated/ui-bundle.js.map +1 -1
  14. package/dist/http.d.ts +4 -0
  15. package/dist/http.d.ts.map +1 -0
  16. package/dist/http.js +23 -0
  17. package/dist/http.js.map +1 -0
  18. package/dist/index.js +8 -13
  19. package/dist/index.js.map +1 -1
  20. package/dist/options.d.ts +26 -0
  21. package/dist/options.d.ts.map +1 -0
  22. package/dist/options.js +55 -0
  23. package/dist/options.js.map +1 -0
  24. package/dist/reference-cheatsheet.d.ts +1 -1
  25. package/dist/reference-cheatsheet.d.ts.map +1 -1
  26. package/dist/reference-cheatsheet.js +27 -2
  27. package/dist/reference-cheatsheet.js.map +1 -1
  28. package/dist/schema-vocab.d.ts +4 -2
  29. package/dist/schema-vocab.d.ts.map +1 -1
  30. package/dist/schema-vocab.js +9 -0
  31. package/dist/schema-vocab.js.map +1 -1
  32. package/dist/schemas.d.ts +2 -0
  33. package/dist/schemas.d.ts.map +1 -1
  34. package/dist/schemas.js +2 -0
  35. package/dist/schemas.js.map +1 -1
  36. package/dist/server.d.ts +4 -1
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +172 -82
  39. package/dist/server.js.map +1 -1
  40. package/dist/ui/entry.js +5 -12
  41. package/dist/ui/entry.js.map +1 -1
  42. package/dist/ui/payload.d.ts +14 -0
  43. package/dist/ui/payload.d.ts.map +1 -1
  44. package/dist/ui/payload.js +23 -0
  45. package/dist/ui/payload.js.map +1 -1
  46. package/dist/workspace-roots.d.ts +23 -0
  47. package/dist/workspace-roots.d.ts.map +1 -0
  48. package/dist/workspace-roots.js +85 -0
  49. package/dist/workspace-roots.js.map +1 -0
  50. package/package.json +21 -16
  51. package/src/capabilities.ts +4 -2
  52. package/src/diagnostics.ts +99 -39
  53. package/src/generated/resources.ts +15 -3
  54. package/src/generated/ui-bundle.ts +2 -2
  55. package/src/http.ts +28 -0
  56. package/src/index.ts +8 -14
  57. package/src/options.ts +79 -0
  58. package/src/reference-cheatsheet.ts +27 -2
  59. package/src/schema-vocab.ts +9 -0
  60. package/src/schemas.ts +2 -0
  61. package/src/server.ts +229 -84
  62. package/src/ui/entry.ts +6 -11
  63. package/src/ui/payload.ts +26 -0
  64. package/src/workspace-roots.ts +99 -0
package/src/server.ts CHANGED
@@ -15,14 +15,19 @@ import {
15
15
  registerAppTool,
16
16
  } from '@modelcontextprotocol/ext-apps/server';
17
17
  import { McpServer } from '@modelcontextprotocol/server';
18
+ import type { SurfaceOptions } from '@nowline/config';
18
19
  import { type NowlineFile, parseNowlineJson, printNowlineFile } from '@nowline/core';
19
20
  import {
21
+ acceptStagedRoadmap,
20
22
  type ExportFormat,
21
23
  exportDocument,
24
+ exportStaged,
22
25
  type HostEnv,
26
+ IncludeResolveError,
23
27
  type RenderInputs,
24
28
  } from '@nowline/export';
25
29
  import { resolveFonts } from '@nowline/export-core';
30
+ import { NON_WORKING_DISPLAYS, THEME_NAMES } from '@nowline/layout';
26
31
  import { buildShareLink } from '@nowline/share-link';
27
32
  import { z } from 'zod';
28
33
  import { NOWLINE_MCP_ICONS } from './branding.js';
@@ -30,14 +35,20 @@ import { CAPABILITIES } from './capabilities.js';
30
35
  import {
31
36
  buildDocument,
32
37
  collectMcpDiagnostics,
33
- collectMcpLayoutInsights,
34
38
  DEFAULT_RENDER_WIDTH,
35
39
  diagnosticsErrorBlock,
40
+ diagnosticsErrorResponse,
36
41
  handleToolError,
37
42
  InputRequiredError,
43
+ includeDiagnosticsResponse,
38
44
  LAYOUT_INSIGHT_HINT,
45
+ layoutInsightsFromStage,
46
+ type McpInsight,
39
47
  PathOutsideRootError,
40
48
  REVIEW_MAX_WIDTH,
49
+ routedResolveDiagnosticsToMcp,
50
+ stageMcpRoadmap,
51
+ stageRoutedDiagnostics,
41
52
  toolDescriptionWithSyntax,
42
53
  } from './diagnostics.js';
43
54
  import {
@@ -47,6 +58,7 @@ import {
47
58
  REFERENCE_MAN_PAGE,
48
59
  } from './generated/resources.js';
49
60
  import { PREVIEW_HTML } from './generated/ui-bundle.js';
61
+ import { type McpResolvedOptions, resolveMcpOptions } from './options.js';
50
62
  import { registerPrompts } from './prompts.js';
51
63
  import { REFERENCE_CHEATSHEET } from './reference-cheatsheet.js';
52
64
  import { SCHEMA_VOCABULARY } from './schema-vocab.js';
@@ -67,6 +79,7 @@ import {
67
79
  UpdateOutputSchema,
68
80
  ValidateOutputSchema,
69
81
  } from './schemas.js';
82
+ import { createWorkspaceRoots, isInsideRoots, type WorkspaceRoots } from './workspace-roots.js';
70
83
 
71
84
  // ---- MCP Apps UI (in-chat live preview) -------------------------------------
72
85
  //
@@ -107,9 +120,24 @@ interface PreviewPayload {
107
120
  theme?: string;
108
121
  now?: string;
109
122
  width?: number;
123
+ nonWorking?: 'hide' | 'show';
110
124
  locale?: string;
111
125
  }
112
126
 
127
+ /**
128
+ * The preview payload's now-line. An explicit `now` passes through. With only a
129
+ * `timezone`, the payload carries the date that zone resolved to: the preview
130
+ * draws an omitted `now` at the viewer's own date and cannot read the zone.
131
+ */
132
+ function previewNow(
133
+ args: { now?: string; timezone?: string },
134
+ today: Date | undefined,
135
+ ): string | undefined {
136
+ if (args.now !== undefined) return args.now;
137
+ if (args.timezone !== undefined && today) return today.toISOString().slice(0, 10);
138
+ return undefined;
139
+ }
140
+
113
141
  function leanPreviewBlock(payload: PreviewPayload) {
114
142
  return {
115
143
  type: 'text' as const,
@@ -119,6 +147,7 @@ function leanPreviewBlock(payload: PreviewPayload) {
119
147
  theme: payload.theme,
120
148
  now: payload.now,
121
149
  width: payload.width,
150
+ nonWorking: payload.nonWorking,
122
151
  locale: payload.locale,
123
152
  }),
124
153
  };
@@ -146,11 +175,10 @@ function mutatingTool(title: string, opts: { destructiveHint: boolean; idempoten
146
175
 
147
176
  // ---- Server factory ---------------------------------------------------------
148
177
 
149
- function resolveAndGuard(filePath: string, allowedRoot: string): string {
150
- const abs = path.resolve(allowedRoot, filePath);
151
- const guard = path.resolve(allowedRoot);
152
- if (!abs.startsWith(guard + path.sep) && abs !== guard) {
153
- throw new PathOutsideRootError(filePath, allowedRoot);
178
+ function resolveAndGuard(filePath: string, roots: WorkspaceRoots): string {
179
+ const abs = path.resolve(roots.primary, filePath);
180
+ if (!isInsideRoots(abs, roots)) {
181
+ throw new PathOutsideRootError(filePath, roots.allowed.join(', '));
154
182
  }
155
183
  return abs;
156
184
  }
@@ -185,11 +213,6 @@ function createNodeHostEnv(sourcePath: string): HostEnv {
185
213
 
186
214
  // ---- Helpers ----------------------------------------------------------------
187
215
 
188
- function todayUtc(): Date {
189
- const now = new Date();
190
- return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
191
- }
192
-
193
216
  function exampleShortName(fullName: string): string {
194
217
  return fullName.endsWith('.nowline') ? fullName.slice(0, -'.nowline'.length) : fullName;
195
218
  }
@@ -201,13 +224,13 @@ function findExample(name: string): ExampleFile | undefined {
201
224
 
202
225
  async function sourceAndPath(
203
226
  args: { source?: string; path?: string },
204
- allowedRoot: string,
227
+ roots: WorkspaceRoots,
205
228
  ): Promise<{ source: string; filePath: string }> {
206
229
  if (args.source !== undefined && args.path === undefined) {
207
- return { source: args.source, filePath: path.join(allowedRoot, 'unnamed.nowline') };
230
+ return { source: args.source, filePath: path.join(roots.primary, 'unnamed.nowline') };
208
231
  }
209
232
  if (args.path !== undefined) {
210
- const abs = resolveAndGuard(args.path, allowedRoot);
233
+ const abs = resolveAndGuard(args.path, roots);
211
234
  const source = args.source ?? (await fs.readFile(abs, 'utf-8'));
212
235
  return { source, filePath: abs };
213
236
  }
@@ -217,7 +240,10 @@ async function sourceAndPath(
217
240
  // ---- Server factory ---------------------------------------------------------
218
241
 
219
242
  export interface McpServerOptions {
220
- /** Working directory — all file paths are resolved relative to this root. Defaults to process.cwd(). */
243
+ /**
244
+ * Working directory: all file paths resolve relative to this root. When omitted, the
245
+ * server uses the client's MCP roots if it advertises them, else process.cwd().
246
+ */
221
247
  allowedRoot?: string;
222
248
  /** Whether the allowed root was explicitly configured (vs. defaulting to cwd). Controls smart export delivery for binary formats. Defaults to false. */
223
249
  rootConfigured?: boolean;
@@ -228,7 +254,6 @@ export interface McpServerOptions {
228
254
  }
229
255
 
230
256
  export function createMcpServer(opts: McpServerOptions = {}): McpServer {
231
- const allowedRoot = opts.allowedRoot ?? process.cwd();
232
257
  const rootConfigured = opts.rootConfigured ?? false;
233
258
  const server = new McpServer(
234
259
  {
@@ -256,6 +281,8 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
256
281
  },
257
282
  );
258
283
 
284
+ const workspaceRoots = createWorkspaceRoots(server, opts.allowedRoot);
285
+
259
286
  // ---- Resources ----------------------------------------------------------
260
287
 
261
288
  server.registerResource(
@@ -335,6 +362,43 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
335
362
 
336
363
  registerPrompts(server);
337
364
 
365
+ const nonWorkingSchema = z
366
+ .enum(NON_WORKING_DISPLAYS)
367
+ .optional()
368
+ .describe(
369
+ "How a business calendar's non-working days appear: hide collapses them, " +
370
+ "show draws them as shaded bands. Overrides the file's `default roadmap non-working:` key; " +
371
+ 'omit to let the key apply (then hide). The schedule exports (mermaid, msproj, xlsx) are the same either way.',
372
+ );
373
+
374
+ // A string, not an enum, so `Greyscale` and the `greyscale` alias reach
375
+ // `resolveMcpOptions`, which rejects an unknown name with the theme list.
376
+ const themeSchema = z
377
+ .string()
378
+ .optional()
379
+ .describe(
380
+ `Color theme: ${THEME_NAMES.join(', ')} ("greyscale" is accepted). Defaults to light.`,
381
+ );
382
+
383
+ const nowSchema = z
384
+ .string()
385
+ .regex(/^(\d{4}-\d{2}-\d{2}|-)$/, 'Expected YYYY-MM-DD or "-".')
386
+ .optional()
387
+ .describe(
388
+ "Now-line date as YYYY-MM-DD. Omit to draw it at today's date in `timezone` " +
389
+ "(the server's local zone when `timezone` is omitted too); " +
390
+ 'pass "-" to suppress the now-line (same as the CLI\'s `--now -`).',
391
+ );
392
+
393
+ const timezoneSchema = z
394
+ .string()
395
+ .optional()
396
+ .describe(
397
+ "Timezone that decides today's date when `now` is omitted (ignored when `now` is a date): " +
398
+ '"local" (the default), "UTC", an ISO 8601 offset ("+05:30", "-07:00", "Z"), ' +
399
+ 'or an IANA name ("America/Los_Angeles"). The same forms as the CLI\'s `--timezone`.',
400
+ );
401
+
338
402
  // ---- validate -----------------------------------------------------------
339
403
 
340
404
  server.registerTool(
@@ -349,26 +413,39 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
349
413
  .string()
350
414
  .optional()
351
415
  .describe('Absolute or relative path to a .nowline file to validate.'),
416
+ now: nowSchema,
417
+ timezone: timezoneSchema,
352
418
  }),
353
419
  outputSchema: ValidateOutputSchema,
354
420
  annotations: readOnlyTool('Validate Roadmap'),
355
421
  },
356
422
  async (args) => {
357
423
  try {
358
- const { source, filePath } = await sourceAndPath(args, allowedRoot);
424
+ const { options } = resolveMcpOptions(args);
425
+ const { source, filePath } = await sourceAndPath(args, await workspaceRoots());
359
426
  const doc = await buildDocument(source);
360
427
  const diagnostics = collectMcpDiagnostics(doc, filePath);
428
+ // One stage: the routed (wave-rule) include diagnostics join
429
+ // the validator's, with the file and line they point into
430
+ // (specs/waves.md §6.1), and the insights come from the same
431
+ // layout, logo included.
432
+ let insights: McpInsight[] = [];
433
+ if (diagnostics.every((d) => d.severity !== 'error')) {
434
+ const stage = await stageMcpRoadmap(
435
+ doc,
436
+ source,
437
+ {
438
+ sourcePath: filePath,
439
+ today: options.today,
440
+ locale: 'en-US',
441
+ theme: 'light',
442
+ },
443
+ createNodeHostEnv(filePath),
444
+ );
445
+ diagnostics.push(...stageRoutedDiagnostics(stage));
446
+ if (stage.kind === 'staged') insights = layoutInsightsFromStage(stage);
447
+ }
361
448
  const ok = diagnostics.every((d) => d.severity !== 'error');
362
- const insights = ok
363
- ? await collectMcpLayoutInsights({
364
- source,
365
- filePath,
366
- today: todayUtc(),
367
- locale: 'en-US',
368
- readFile: createNodeHostEnv(filePath).readSource,
369
- doc,
370
- })
371
- : [];
372
449
  const structured = {
373
450
  ok,
374
451
  diagnostics,
@@ -405,7 +482,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
405
482
  },
406
483
  async (args) => {
407
484
  try {
408
- const abs = resolveAndGuard(args.path, allowedRoot);
485
+ const abs = resolveAndGuard(args.path, await workspaceRoots());
409
486
  const source = await fs.readFile(abs, 'utf-8');
410
487
  const structured = { path: abs, source };
411
488
  return {
@@ -439,7 +516,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
439
516
  },
440
517
  async (args) => {
441
518
  try {
442
- const abs = resolveAndGuard(args.path, allowedRoot);
519
+ const abs = resolveAndGuard(args.path, await workspaceRoots());
443
520
  const blocked = await diagnosticsErrorBlock(args.source, abs);
444
521
  if (!blocked.ok) return blocked.response;
445
522
  await fs.mkdir(path.dirname(abs), { recursive: true });
@@ -477,7 +554,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
477
554
  },
478
555
  async (args) => {
479
556
  try {
480
- const abs = resolveAndGuard(args.path, allowedRoot);
557
+ const abs = resolveAndGuard(args.path, await workspaceRoots());
481
558
  const blocked = await diagnosticsErrorBlock(args.source, abs);
482
559
  if (!blocked.ok) return blocked.response;
483
560
  await fs.writeFile(abs, args.source, 'utf-8');
@@ -508,7 +585,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
508
585
  },
509
586
  async (args) => {
510
587
  try {
511
- const abs = resolveAndGuard(args.path, allowedRoot);
588
+ const abs = resolveAndGuard(args.path, await workspaceRoots());
512
589
  await fs.unlink(abs);
513
590
  const structured = { path: abs };
514
591
  return {
@@ -532,7 +609,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
532
609
  .string()
533
610
  .optional()
534
611
  .describe(
535
- 'Directory to scan. Defaults to the allowed root (cwd or --root). Absolute or relative.',
612
+ "Directory to scan. Defaults to the project root (--root, else the client's first MCP root, else cwd). Absolute or relative.",
536
613
  ),
537
614
  recursive: z
538
615
  .boolean()
@@ -544,9 +621,8 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
544
621
  },
545
622
  async (args) => {
546
623
  try {
547
- const dir = args.directory
548
- ? resolveAndGuard(args.directory, allowedRoot)
549
- : allowedRoot;
624
+ const roots = await workspaceRoots();
625
+ const dir = args.directory ? resolveAndGuard(args.directory, roots) : roots.primary;
550
626
  const recursive = args.recursive ?? false;
551
627
  const paths = await listNowlineFiles(dir, recursive);
552
628
  const structured = { paths };
@@ -585,15 +661,16 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
585
661
  .enum(['svg', 'png'])
586
662
  .optional()
587
663
  .describe('Output format. Defaults to svg.'),
588
- theme: z
589
- .enum(['light', 'dark', 'grayscale'])
590
- .optional()
591
- .describe('Color theme. Defaults to light.'),
592
- now: z
593
- .string()
664
+ theme: themeSchema,
665
+ now: nowSchema,
666
+ timezone: timezoneSchema,
667
+ width: z
668
+ .number()
594
669
  .optional()
595
- .describe('Now-line date as YYYY-MM-DD (UTC). Omit to suppress.'),
596
- width: z.number().optional().describe('Canvas width in px.'),
670
+ .describe(
671
+ 'Maximum canvas width in px, an integer of at least 320. Unset fits the content; a wider roadmap shrinks its timeline to fit.',
672
+ ),
673
+ nonWorking: nonWorkingSchema,
597
674
  scale: z
598
675
  .number()
599
676
  .optional()
@@ -631,10 +708,17 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
631
708
  // Only the filesystem touchpoints (reading the input, writing the
632
709
  // output) map to structured NL.MCP.* errors; a kernel render fault
633
710
  // bubbles unchanged so it is never mislabeled as a path/IO failure.
711
+ let resolved: McpResolvedOptions;
712
+ try {
713
+ resolved = resolveMcpOptions(args);
714
+ } catch (err) {
715
+ return handleToolError(err);
716
+ }
717
+ const { theme, options } = resolved;
634
718
  let source: string;
635
719
  let filePath: string;
636
720
  try {
637
- ({ source, filePath } = await sourceAndPath(args, allowedRoot));
721
+ ({ source, filePath } = await sourceAndPath(args, await workspaceRoots()));
638
722
  } catch (err) {
639
723
  return handleToolError(err, args.path);
640
724
  }
@@ -643,13 +727,14 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
643
727
  if (!blocked.ok) return blocked.response;
644
728
 
645
729
  const format: ExportFormat = args.format ?? 'svg';
646
- const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
730
+ const today = options.today;
647
731
  const inputs: RenderInputs = {
648
732
  sourcePath: filePath,
649
733
  today,
650
734
  locale: 'en-US',
651
- theme: args.theme ?? 'light',
652
- width: args.width,
735
+ theme: options.theme,
736
+ width: options.width,
737
+ nonWorking: options.nonWorking,
653
738
  pngScale: args.scale,
654
739
  };
655
740
  const host = createNodeHostEnv(filePath);
@@ -662,32 +747,49 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
662
747
  const result = await resolveFonts({ headless: true });
663
748
  inputs.fonts = { sans: result.sans, mono: result.mono };
664
749
  }
665
- const bytes = needsRender
666
- ? await exportDocument(source, format, inputs, host)
667
- : new Uint8Array(0);
668
-
669
- const insights = await collectMcpLayoutInsights({
670
- source,
671
- filePath,
672
- today,
673
- theme: args.theme ?? 'light',
674
- width: args.width,
675
- locale: 'en-US',
676
- readFile: host.readSource,
677
- doc: blocked.doc,
678
- });
750
+ // One stage serves the render, the insights and the include check.
751
+ const stage = await stageMcpRoadmap(blocked.doc, source, inputs, host);
752
+ if (stage.kind === 'invalid') {
753
+ return diagnosticsErrorResponse(
754
+ filePath,
755
+ collectMcpDiagnostics(blocked.doc, filePath),
756
+ );
757
+ }
758
+ // Wave-rule include errors are reported as diagnostics. An uncoded
759
+ // include error throws from a render, as the kernel's does; the
760
+ // preview path, which draws nothing here, keeps the preview it
761
+ // returned before waves existed (specs/waves.md §6.1).
762
+ const routed = stageRoutedDiagnostics(stage);
763
+ if (routed.some((d) => d.severity === 'error')) {
764
+ return includeDiagnosticsResponse(blocked.doc, filePath, routed);
765
+ }
766
+ if (stage.kind === 'include-error' && needsRender) acceptStagedRoadmap(stage, inputs);
767
+ const staged = stage.kind === 'staged' ? stage : undefined;
768
+ const insights = staged ? layoutInsightsFromStage(staged) : [];
769
+ const bytes =
770
+ needsRender && staged
771
+ ? await exportStaged(staged, format, inputs, host)
772
+ : new Uint8Array(0);
679
773
 
680
774
  const previewPayload: PreviewPayload = {
681
775
  source,
682
- theme: args.theme,
683
- now: args.now,
684
- width: args.width,
776
+ theme,
777
+ now: previewNow(args, options.today),
778
+ width: options.width,
779
+ nonWorking: options.nonWorking,
685
780
  locale: 'en-US',
686
781
  };
687
782
 
688
783
  const reviewBlocks =
689
784
  args.review === true
690
- ? await buildReviewContentBlocks(source, format, bytes, inputs, host)
785
+ ? await buildReviewContentBlocks(
786
+ blocked.doc,
787
+ source,
788
+ format,
789
+ bytes,
790
+ inputs,
791
+ host,
792
+ )
691
793
  : [];
692
794
 
693
795
  const insightHintBlocks =
@@ -696,7 +798,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
696
798
 
697
799
  if (args.output) {
698
800
  try {
699
- const outAbs = resolveAndGuard(args.output, allowedRoot);
801
+ const outAbs = resolveAndGuard(args.output, await workspaceRoots());
700
802
  await fs.mkdir(path.dirname(outAbs), { recursive: true });
701
803
  await fs.writeFile(outAbs, bytes);
702
804
  const structured = {
@@ -802,10 +904,17 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
802
904
  'Never pass a virtual or sandbox path such as /home/claude/…, /mnt/user-data/…, or any path ' +
803
905
  'outside the configured output folder — those do not exist on the host filesystem.',
804
906
  ),
805
- now: z.string().optional().describe('Now-line date as YYYY-MM-DD (UTC).'),
806
- theme: z.enum(['light', 'dark', 'grayscale']).optional(),
907
+ now: nowSchema,
908
+ timezone: timezoneSchema,
909
+ theme: themeSchema,
910
+ nonWorking: nonWorkingSchema,
807
911
  scale: z.number().optional().describe('PNG scale factor.'),
808
- pageSize: z.string().optional().describe('PDF page size.'),
912
+ pageSize: z
913
+ .string()
914
+ .optional()
915
+ .describe(
916
+ 'PDF page size: a preset name, "content", or <W>x<H><unit> (e.g. 8.5x11in). Validated for every format.',
917
+ ),
809
918
  orientation: z
810
919
  .enum(['portrait', 'landscape', 'auto'])
811
920
  .optional()
@@ -834,10 +943,16 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
834
943
  // Only the filesystem touchpoints (reading the input, writing the
835
944
  // output) map to structured NL.MCP.* errors; a kernel export fault
836
945
  // bubbles unchanged so it is never mislabeled as a path/IO failure.
946
+ let options: SurfaceOptions;
947
+ try {
948
+ ({ options } = resolveMcpOptions(args));
949
+ } catch (err) {
950
+ return handleToolError(err);
951
+ }
837
952
  let source: string;
838
953
  let filePath: string;
839
954
  try {
840
- ({ source, filePath } = await sourceAndPath(args, allowedRoot));
955
+ ({ source, filePath } = await sourceAndPath(args, await workspaceRoots()));
841
956
  } catch (err) {
842
957
  return handleToolError(err, args.path);
843
958
  }
@@ -846,14 +961,14 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
846
961
  if (!blocked.ok) return blocked.response;
847
962
 
848
963
  const format: ExportFormat = args.format as NonRenderFormat;
849
- const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
850
964
  const inputs: RenderInputs = {
851
965
  sourcePath: filePath,
852
- today,
966
+ today: options.today,
853
967
  locale: 'en-US',
854
- theme: args.theme ?? 'light',
968
+ theme: options.theme,
969
+ nonWorking: options.nonWorking,
855
970
  pngScale: args.scale,
856
- pageSize: args.pageSize,
971
+ pageSize: options.pageSize,
857
972
  orientation: args.orientation,
858
973
  marginPt: args.marginPt,
859
974
  msprojStart: args.msprojStart,
@@ -863,7 +978,21 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
863
978
  inputs.fonts = { sans: result.sans, mono: result.mono };
864
979
  }
865
980
  const host = createNodeHostEnv(filePath);
866
- const bytes = await exportDocument(source, format, inputs, host);
981
+ let bytes: Uint8Array;
982
+ try {
983
+ const staged = acceptStagedRoadmap(
984
+ await stageMcpRoadmap(blocked.doc, source, inputs, host),
985
+ inputs,
986
+ );
987
+ bytes = await exportStaged(staged, format, inputs, host);
988
+ } catch (err) {
989
+ if (!(err instanceof IncludeResolveError)) throw err;
990
+ return includeDiagnosticsResponse(
991
+ blocked.doc,
992
+ filePath,
993
+ routedResolveDiagnosticsToMcp(err.diagnostics),
994
+ );
995
+ }
867
996
 
868
997
  const BINARY_FORMATS = new Set<ExportFormat>(['png', 'pdf', 'xlsx']);
869
998
  const isBinary = BINARY_FORMATS.has(format);
@@ -917,11 +1046,12 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
917
1046
  }
918
1047
 
919
1048
  // Write to disk if needed.
920
- const outTarget = args.output ?? path.join(allowedRoot, defaultFilename);
1049
+ const roots = await workspaceRoots();
1050
+ const outTarget = args.output ?? path.join(roots.primary, defaultFilename);
921
1051
  let writtenPath: string | undefined;
922
1052
  if (doWrite) {
923
1053
  try {
924
- const outAbs = resolveAndGuard(outTarget, allowedRoot);
1054
+ const outAbs = resolveAndGuard(outTarget, roots);
925
1055
  await fs.mkdir(path.dirname(outAbs), { recursive: true });
926
1056
  await fs.writeFile(outAbs, bytes);
927
1057
  writtenPath = outAbs;
@@ -1000,7 +1130,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
1000
1130
  let source: string;
1001
1131
  let filePath: string;
1002
1132
  try {
1003
- ({ source, filePath } = await sourceAndPath(args, allowedRoot));
1133
+ ({ source, filePath } = await sourceAndPath(args, await workspaceRoots()));
1004
1134
  } catch (err) {
1005
1135
  return handleToolError(err, args.path);
1006
1136
  }
@@ -1049,14 +1179,17 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
1049
1179
  async (args) => {
1050
1180
  try {
1051
1181
  if (args.to === 'json') {
1052
- const { source, filePath } = await sourceAndPath(args, allowedRoot);
1182
+ const { source, filePath } = await sourceAndPath(args, await workspaceRoots());
1053
1183
  const host = createNodeHostEnv(filePath);
1184
+ // The JSON AST does not depend on today's date, so convert
1185
+ // takes no `now`; the kernel still wants a concrete one.
1186
+ const { options } = resolveMcpOptions({});
1054
1187
  const jsonBytes = await exportDocument(
1055
1188
  source,
1056
1189
  'json',
1057
1190
  {
1058
1191
  sourcePath: filePath,
1059
- today: todayUtc(),
1192
+ today: options.today,
1060
1193
  locale: 'en-US',
1061
1194
  theme: 'light',
1062
1195
  },
@@ -1074,7 +1207,10 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
1074
1207
  const jsonSource =
1075
1208
  args.source ??
1076
1209
  (args.path
1077
- ? await fs.readFile(resolveAndGuard(args.path, allowedRoot), 'utf-8')
1210
+ ? await fs.readFile(
1211
+ resolveAndGuard(args.path, await workspaceRoots()),
1212
+ 'utf-8',
1213
+ )
1078
1214
  : null);
1079
1215
  if (!jsonSource) {
1080
1216
  throw new InputRequiredError('At least one of `source` or `path` is required.');
@@ -1123,7 +1259,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
1123
1259
  server.registerTool(
1124
1260
  'list-themes',
1125
1261
  {
1126
- description: 'List supported color themes: light, dark, grayscale.',
1262
+ description: `List supported color themes: ${THEME_NAMES.join(', ')}.`,
1127
1263
  inputSchema: z.object({}),
1128
1264
  outputSchema: ListItemsOutputSchema,
1129
1265
  annotations: readOnlyTool('List Themes'),
@@ -1302,7 +1438,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
1302
1438
  'schema',
1303
1439
  {
1304
1440
  description:
1305
- 'Return the structured Nowline DSL key vocabulary (directive keys, entity types, item properties).',
1441
+ 'Return the structured Nowline DSL key vocabulary (directive keys, entity types, item properties, config keywords, calendar declaration keys).',
1306
1442
  inputSchema: z.object({}),
1307
1443
  outputSchema: SchemaOutputSchema,
1308
1444
  annotations: readOnlyTool('View Roadmap Schema'),
@@ -1312,6 +1448,8 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
1312
1448
  directiveKeys: [...SCHEMA_VOCABULARY.directiveKeys],
1313
1449
  entityTypes: [...SCHEMA_VOCABULARY.entityTypes],
1314
1450
  itemPropertyKeys: [...SCHEMA_VOCABULARY.itemPropertyKeys],
1451
+ configKeywords: [...SCHEMA_VOCABULARY.configKeywords],
1452
+ calendarDeclarationKeys: [...SCHEMA_VOCABULARY.calendarDeclarationKeys],
1315
1453
  };
1316
1454
  return {
1317
1455
  content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
@@ -1385,6 +1523,7 @@ function buildExportInlineContentBlocks(
1385
1523
  }
1386
1524
 
1387
1525
  async function buildReviewContentBlocks(
1526
+ doc: Awaited<ReturnType<typeof buildDocument>>,
1388
1527
  source: string,
1389
1528
  format: ExportFormat,
1390
1529
  artifactBytes: Uint8Array,
@@ -1415,7 +1554,13 @@ async function buildReviewContentBlocks(
1415
1554
  const fonts = await resolveFonts({ headless: true });
1416
1555
  reviewInputs.fonts = { sans: fonts.sans, mono: fonts.mono };
1417
1556
  }
1418
- inspectionBytes = await exportDocument(source, 'png', reviewInputs, host);
1557
+ // A narrower review width is a different layout, so it stages again
1558
+ // (the parsed document is reused).
1559
+ const reviewStage = acceptStagedRoadmap(
1560
+ await stageMcpRoadmap(doc, source, reviewInputs, host),
1561
+ reviewInputs,
1562
+ );
1563
+ inspectionBytes = await exportStaged(reviewStage, 'png', reviewInputs, host);
1419
1564
  }
1420
1565
 
1421
1566
  if (format !== 'png' || inspectionBytes.byteLength !== artifactBytes.byteLength) {
package/src/ui/entry.ts CHANGED
@@ -25,12 +25,15 @@
25
25
  declare const __MCP_VERSION__: string;
26
26
 
27
27
  import { App } from '@modelcontextprotocol/ext-apps';
28
+ import { normalizeThemeName } from '@nowline/layout';
28
29
  import { type LivePreviewHandle, mountLivePreview } from '@nowline/preview';
29
30
  import type { NowOverride, ThemeOverride } from '@nowline/preview-shell';
30
31
  import {
31
32
  type PreviewPayload,
32
33
  parsePreviewFromArguments,
33
34
  parsePreviewFromContent,
35
+ toNonWorkingOverride,
36
+ toNowOverride,
34
37
  } from './payload.js';
35
38
 
36
39
  function readPayload(): PreviewPayload | undefined {
@@ -45,16 +48,7 @@ function readPayload(): PreviewPayload | undefined {
45
48
 
46
49
  /** Coerce a raw theme token from the payload to the shell's ThemeOverride. */
47
50
  function toThemeOverride(theme: string | undefined): ThemeOverride {
48
- switch (theme) {
49
- case 'light':
50
- case 'dark':
51
- case 'grayscale':
52
- case 'greyscale':
53
- case 'auto':
54
- return theme;
55
- default:
56
- return 'auto';
57
- }
51
+ return (theme !== undefined ? normalizeThemeName(theme) : undefined) ?? 'auto';
58
52
  }
59
53
 
60
54
  let mounted = false;
@@ -87,7 +81,8 @@ function mountFromPayload(payload: PreviewPayload, app: App): void {
87
81
  mode: hostTheme(app) ?? 'system',
88
82
  initialView: {
89
83
  theme: toThemeOverride(payload.theme),
90
- now: (payload.now ?? 'today') as NowOverride,
84
+ now: toNowOverride(payload.now) as NowOverride,
85
+ nonWorking: toNonWorkingOverride(payload.nonWorking),
91
86
  showLinks: payload.showLinks !== false,
92
87
  },
93
88
  renderOptions: {