@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/dist/server.js CHANGED
@@ -10,19 +10,22 @@ import * as path from 'node:path';
10
10
  import { getUiCapability, RESOURCE_MIME_TYPE, registerAppResource, registerAppTool, } from '@modelcontextprotocol/ext-apps/server';
11
11
  import { McpServer } from '@modelcontextprotocol/server';
12
12
  import { parseNowlineJson, printNowlineFile } from '@nowline/core';
13
- import { exportDocument, } from '@nowline/export';
13
+ import { acceptStagedRoadmap, exportDocument, exportStaged, IncludeResolveError, } from '@nowline/export';
14
14
  import { resolveFonts } from '@nowline/export-core';
15
+ import { NON_WORKING_DISPLAYS, THEME_NAMES } from '@nowline/layout';
15
16
  import { buildShareLink } from '@nowline/share-link';
16
17
  import { z } from 'zod';
17
18
  import { NOWLINE_MCP_ICONS } from './branding.js';
18
19
  import { CAPABILITIES } from './capabilities.js';
19
- import { buildDocument, collectMcpDiagnostics, collectMcpLayoutInsights, DEFAULT_RENDER_WIDTH, diagnosticsErrorBlock, handleToolError, InputRequiredError, LAYOUT_INSIGHT_HINT, PathOutsideRootError, REVIEW_MAX_WIDTH, toolDescriptionWithSyntax, } from './diagnostics.js';
20
+ import { buildDocument, collectMcpDiagnostics, DEFAULT_RENDER_WIDTH, diagnosticsErrorBlock, diagnosticsErrorResponse, handleToolError, InputRequiredError, includeDiagnosticsResponse, LAYOUT_INSIGHT_HINT, layoutInsightsFromStage, PathOutsideRootError, REVIEW_MAX_WIDTH, routedResolveDiagnosticsToMcp, stageMcpRoadmap, stageRoutedDiagnostics, toolDescriptionWithSyntax, } from './diagnostics.js';
20
21
  import { CONVERSIONS_GUIDE, EXAMPLES, REFERENCE_MAN_PAGE, } from './generated/resources.js';
21
22
  import { PREVIEW_HTML } from './generated/ui-bundle.js';
23
+ import { resolveMcpOptions } from './options.js';
22
24
  import { registerPrompts } from './prompts.js';
23
25
  import { REFERENCE_CHEATSHEET } from './reference-cheatsheet.js';
24
26
  import { SCHEMA_VOCABULARY } from './schema-vocab.js';
25
27
  import { CapabilitiesOutputSchema, ConvertOutputSchema, CreateOutputSchema, DeleteOutputSchema, ExamplesOutputSchema, ExportOutputSchema, ListItemsOutputSchema, ListOutputSchema, ReadOutputSchema, ReferenceOutputSchema, RenderOutputSchema, SchemaOutputSchema, ShareOutputSchema, UpdateOutputSchema, ValidateOutputSchema, } from './schemas.js';
28
+ import { createWorkspaceRoots, isInsideRoots } from './workspace-roots.js';
26
29
  // ---- MCP Apps UI (in-chat live preview) -------------------------------------
27
30
  //
28
31
  // Official MCP Apps model (SEP-1865): a pre-declared ui:// resource serves
@@ -46,6 +49,18 @@ function clientSupportsAppsUi(server) {
46
49
  const buckets = [caps.extensions, caps.experimental].filter((bucket) => Boolean(bucket));
47
50
  return buckets.some((bucket) => Boolean(bucket[MCP_APPS_UI_CAPABILITY] || bucket.ui || bucket.apps));
48
51
  }
52
+ /**
53
+ * The preview payload's now-line. An explicit `now` passes through. With only a
54
+ * `timezone`, the payload carries the date that zone resolved to: the preview
55
+ * draws an omitted `now` at the viewer's own date and cannot read the zone.
56
+ */
57
+ function previewNow(args, today) {
58
+ if (args.now !== undefined)
59
+ return args.now;
60
+ if (args.timezone !== undefined && today)
61
+ return today.toISOString().slice(0, 10);
62
+ return undefined;
63
+ }
49
64
  function leanPreviewBlock(payload) {
50
65
  return {
51
66
  type: 'text',
@@ -55,6 +70,7 @@ function leanPreviewBlock(payload) {
55
70
  theme: payload.theme,
56
71
  now: payload.now,
57
72
  width: payload.width,
73
+ nonWorking: payload.nonWorking,
58
74
  locale: payload.locale,
59
75
  }),
60
76
  };
@@ -77,11 +93,10 @@ function mutatingTool(title, opts) {
77
93
  };
78
94
  }
79
95
  // ---- Server factory ---------------------------------------------------------
80
- function resolveAndGuard(filePath, allowedRoot) {
81
- const abs = path.resolve(allowedRoot, filePath);
82
- const guard = path.resolve(allowedRoot);
83
- if (!abs.startsWith(guard + path.sep) && abs !== guard) {
84
- throw new PathOutsideRootError(filePath, allowedRoot);
96
+ function resolveAndGuard(filePath, roots) {
97
+ const abs = path.resolve(roots.primary, filePath);
98
+ if (!isInsideRoots(abs, roots)) {
99
+ throw new PathOutsideRootError(filePath, roots.allowed.join(', '));
85
100
  }
86
101
  return abs;
87
102
  }
@@ -112,10 +127,6 @@ function createNodeHostEnv(sourcePath) {
112
127
  };
113
128
  }
114
129
  // ---- Helpers ----------------------------------------------------------------
115
- function todayUtc() {
116
- const now = new Date();
117
- return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
118
- }
119
130
  function exampleShortName(fullName) {
120
131
  return fullName.endsWith('.nowline') ? fullName.slice(0, -'.nowline'.length) : fullName;
121
132
  }
@@ -123,19 +134,18 @@ function findExample(name) {
123
134
  const withExt = name.endsWith('.nowline') ? name : `${name}.nowline`;
124
135
  return EXAMPLES.find((e) => e.name === withExt || e.name === name);
125
136
  }
126
- async function sourceAndPath(args, allowedRoot) {
137
+ async function sourceAndPath(args, roots) {
127
138
  if (args.source !== undefined && args.path === undefined) {
128
- return { source: args.source, filePath: path.join(allowedRoot, 'unnamed.nowline') };
139
+ return { source: args.source, filePath: path.join(roots.primary, 'unnamed.nowline') };
129
140
  }
130
141
  if (args.path !== undefined) {
131
- const abs = resolveAndGuard(args.path, allowedRoot);
142
+ const abs = resolveAndGuard(args.path, roots);
132
143
  const source = args.source ?? (await fs.readFile(abs, 'utf-8'));
133
144
  return { source, filePath: abs };
134
145
  }
135
146
  throw new InputRequiredError('At least one of `source` or `path` is required.');
136
147
  }
137
148
  export function createMcpServer(opts = {}) {
138
- const allowedRoot = opts.allowedRoot ?? process.cwd();
139
149
  const rootConfigured = opts.rootConfigured ?? false;
140
150
  const server = new McpServer({
141
151
  name: opts.name ?? 'nowline',
@@ -158,6 +168,7 @@ export function createMcpServer(opts = {}) {
158
168
  'Use the `share` tool only when the user explicitly wants a link they can open or share ' +
159
169
  '(opens the free Nowline web app, where they can also export).',
160
170
  });
171
+ const workspaceRoots = createWorkspaceRoots(server, opts.allowedRoot);
161
172
  // ---- Resources ----------------------------------------------------------
162
173
  server.registerResource('nowline-reference', 'nowline://reference', {
163
174
  title: 'Roadmap Reference',
@@ -206,6 +217,31 @@ export function createMcpServer(opts = {}) {
206
217
  }));
207
218
  // ---- Prompts ------------------------------------------------------------
208
219
  registerPrompts(server);
220
+ const nonWorkingSchema = z
221
+ .enum(NON_WORKING_DISPLAYS)
222
+ .optional()
223
+ .describe("How a business calendar's non-working days appear: hide collapses them, " +
224
+ "show draws them as shaded bands. Overrides the file's `default roadmap non-working:` key; " +
225
+ 'omit to let the key apply (then hide). The schedule exports (mermaid, msproj, xlsx) are the same either way.');
226
+ // A string, not an enum, so `Greyscale` and the `greyscale` alias reach
227
+ // `resolveMcpOptions`, which rejects an unknown name with the theme list.
228
+ const themeSchema = z
229
+ .string()
230
+ .optional()
231
+ .describe(`Color theme: ${THEME_NAMES.join(', ')} ("greyscale" is accepted). Defaults to light.`);
232
+ const nowSchema = z
233
+ .string()
234
+ .regex(/^(\d{4}-\d{2}-\d{2}|-)$/, 'Expected YYYY-MM-DD or "-".')
235
+ .optional()
236
+ .describe("Now-line date as YYYY-MM-DD. Omit to draw it at today's date in `timezone` " +
237
+ "(the server's local zone when `timezone` is omitted too); " +
238
+ 'pass "-" to suppress the now-line (same as the CLI\'s `--now -`).');
239
+ const timezoneSchema = z
240
+ .string()
241
+ .optional()
242
+ .describe("Timezone that decides today's date when `now` is omitted (ignored when `now` is a date): " +
243
+ '"local" (the default), "UTC", an ISO 8601 offset ("+05:30", "-07:00", "Z"), ' +
244
+ 'or an IANA name ("America/Los_Angeles"). The same forms as the CLI\'s `--timezone`.');
209
245
  // ---- validate -----------------------------------------------------------
210
246
  server.registerTool('validate', {
211
247
  description: toolDescriptionWithSyntax('Parse and validate a .nowline roadmap. Returns ok=true with optional layout insights when valid, or ok=false with structured diagnostics.'),
@@ -215,25 +251,34 @@ export function createMcpServer(opts = {}) {
215
251
  .string()
216
252
  .optional()
217
253
  .describe('Absolute or relative path to a .nowline file to validate.'),
254
+ now: nowSchema,
255
+ timezone: timezoneSchema,
218
256
  }),
219
257
  outputSchema: ValidateOutputSchema,
220
258
  annotations: readOnlyTool('Validate Roadmap'),
221
259
  }, async (args) => {
222
260
  try {
223
- const { source, filePath } = await sourceAndPath(args, allowedRoot);
261
+ const { options } = resolveMcpOptions(args);
262
+ const { source, filePath } = await sourceAndPath(args, await workspaceRoots());
224
263
  const doc = await buildDocument(source);
225
264
  const diagnostics = collectMcpDiagnostics(doc, filePath);
226
- const ok = diagnostics.every((d) => d.severity !== 'error');
227
- const insights = ok
228
- ? await collectMcpLayoutInsights({
229
- source,
230
- filePath,
231
- today: todayUtc(),
265
+ // One stage: the routed (wave-rule) include diagnostics join
266
+ // the validator's, with the file and line they point into
267
+ // (specs/waves.md §6.1), and the insights come from the same
268
+ // layout, logo included.
269
+ let insights = [];
270
+ if (diagnostics.every((d) => d.severity !== 'error')) {
271
+ const stage = await stageMcpRoadmap(doc, source, {
272
+ sourcePath: filePath,
273
+ today: options.today,
232
274
  locale: 'en-US',
233
- readFile: createNodeHostEnv(filePath).readSource,
234
- doc,
235
- })
236
- : [];
275
+ theme: 'light',
276
+ }, createNodeHostEnv(filePath));
277
+ diagnostics.push(...stageRoutedDiagnostics(stage));
278
+ if (stage.kind === 'staged')
279
+ insights = layoutInsightsFromStage(stage);
280
+ }
281
+ const ok = diagnostics.every((d) => d.severity !== 'error');
237
282
  const structured = {
238
283
  ok,
239
284
  diagnostics,
@@ -265,7 +310,7 @@ export function createMcpServer(opts = {}) {
265
310
  annotations: readOnlyTool('Read Roadmap'),
266
311
  }, async (args) => {
267
312
  try {
268
- const abs = resolveAndGuard(args.path, allowedRoot);
313
+ const abs = resolveAndGuard(args.path, await workspaceRoots());
269
314
  const source = await fs.readFile(abs, 'utf-8');
270
315
  const structured = { path: abs, source };
271
316
  return {
@@ -292,7 +337,7 @@ export function createMcpServer(opts = {}) {
292
337
  }),
293
338
  }, async (args) => {
294
339
  try {
295
- const abs = resolveAndGuard(args.path, allowedRoot);
340
+ const abs = resolveAndGuard(args.path, await workspaceRoots());
296
341
  const blocked = await diagnosticsErrorBlock(args.source, abs);
297
342
  if (!blocked.ok)
298
343
  return blocked.response;
@@ -324,7 +369,7 @@ export function createMcpServer(opts = {}) {
324
369
  }),
325
370
  }, async (args) => {
326
371
  try {
327
- const abs = resolveAndGuard(args.path, allowedRoot);
372
+ const abs = resolveAndGuard(args.path, await workspaceRoots());
328
373
  const blocked = await diagnosticsErrorBlock(args.source, abs);
329
374
  if (!blocked.ok)
330
375
  return blocked.response;
@@ -351,7 +396,7 @@ export function createMcpServer(opts = {}) {
351
396
  annotations: mutatingTool('Delete Roadmap', { destructiveHint: true }),
352
397
  }, async (args) => {
353
398
  try {
354
- const abs = resolveAndGuard(args.path, allowedRoot);
399
+ const abs = resolveAndGuard(args.path, await workspaceRoots());
355
400
  await fs.unlink(abs);
356
401
  const structured = { path: abs };
357
402
  return {
@@ -370,7 +415,7 @@ export function createMcpServer(opts = {}) {
370
415
  directory: z
371
416
  .string()
372
417
  .optional()
373
- .describe('Directory to scan. Defaults to the allowed root (cwd or --root). Absolute or relative.'),
418
+ .describe("Directory to scan. Defaults to the project root (--root, else the client's first MCP root, else cwd). Absolute or relative."),
374
419
  recursive: z
375
420
  .boolean()
376
421
  .optional()
@@ -380,9 +425,8 @@ export function createMcpServer(opts = {}) {
380
425
  annotations: readOnlyTool('List Roadmaps'),
381
426
  }, async (args) => {
382
427
  try {
383
- const dir = args.directory
384
- ? resolveAndGuard(args.directory, allowedRoot)
385
- : allowedRoot;
428
+ const roots = await workspaceRoots();
429
+ const dir = args.directory ? resolveAndGuard(args.directory, roots) : roots.primary;
386
430
  const recursive = args.recursive ?? false;
387
431
  const paths = await listNowlineFiles(dir, recursive);
388
432
  const structured = { paths };
@@ -412,15 +456,14 @@ export function createMcpServer(opts = {}) {
412
456
  .enum(['svg', 'png'])
413
457
  .optional()
414
458
  .describe('Output format. Defaults to svg.'),
415
- theme: z
416
- .enum(['light', 'dark', 'grayscale'])
417
- .optional()
418
- .describe('Color theme. Defaults to light.'),
419
- now: z
420
- .string()
459
+ theme: themeSchema,
460
+ now: nowSchema,
461
+ timezone: timezoneSchema,
462
+ width: z
463
+ .number()
421
464
  .optional()
422
- .describe('Now-line date as YYYY-MM-DD (UTC). Omit to suppress.'),
423
- width: z.number().optional().describe('Canvas width in px.'),
465
+ .describe('Maximum canvas width in px, an integer of at least 320. Unset fits the content; a wider roadmap shrinks its timeline to fit.'),
466
+ nonWorking: nonWorkingSchema,
424
467
  scale: z
425
468
  .number()
426
469
  .optional()
@@ -451,10 +494,18 @@ export function createMcpServer(opts = {}) {
451
494
  // Only the filesystem touchpoints (reading the input, writing the
452
495
  // output) map to structured NL.MCP.* errors; a kernel render fault
453
496
  // bubbles unchanged so it is never mislabeled as a path/IO failure.
497
+ let resolved;
498
+ try {
499
+ resolved = resolveMcpOptions(args);
500
+ }
501
+ catch (err) {
502
+ return handleToolError(err);
503
+ }
504
+ const { theme, options } = resolved;
454
505
  let source;
455
506
  let filePath;
456
507
  try {
457
- ({ source, filePath } = await sourceAndPath(args, allowedRoot));
508
+ ({ source, filePath } = await sourceAndPath(args, await workspaceRoots()));
458
509
  }
459
510
  catch (err) {
460
511
  return handleToolError(err, args.path);
@@ -463,13 +514,14 @@ export function createMcpServer(opts = {}) {
463
514
  if (!blocked.ok)
464
515
  return blocked.response;
465
516
  const format = args.format ?? 'svg';
466
- const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
517
+ const today = options.today;
467
518
  const inputs = {
468
519
  sourcePath: filePath,
469
520
  today,
470
521
  locale: 'en-US',
471
- theme: args.theme ?? 'light',
472
- width: args.width,
522
+ theme: options.theme,
523
+ width: options.width,
524
+ nonWorking: options.nonWorking,
473
525
  pngScale: args.scale,
474
526
  };
475
527
  const host = createNodeHostEnv(filePath);
@@ -482,34 +534,42 @@ export function createMcpServer(opts = {}) {
482
534
  const result = await resolveFonts({ headless: true });
483
535
  inputs.fonts = { sans: result.sans, mono: result.mono };
484
536
  }
485
- const bytes = needsRender
486
- ? await exportDocument(source, format, inputs, host)
537
+ // One stage serves the render, the insights and the include check.
538
+ const stage = await stageMcpRoadmap(blocked.doc, source, inputs, host);
539
+ if (stage.kind === 'invalid') {
540
+ return diagnosticsErrorResponse(filePath, collectMcpDiagnostics(blocked.doc, filePath));
541
+ }
542
+ // Wave-rule include errors are reported as diagnostics. An uncoded
543
+ // include error throws from a render, as the kernel's does; the
544
+ // preview path, which draws nothing here, keeps the preview it
545
+ // returned before waves existed (specs/waves.md §6.1).
546
+ const routed = stageRoutedDiagnostics(stage);
547
+ if (routed.some((d) => d.severity === 'error')) {
548
+ return includeDiagnosticsResponse(blocked.doc, filePath, routed);
549
+ }
550
+ if (stage.kind === 'include-error' && needsRender)
551
+ acceptStagedRoadmap(stage, inputs);
552
+ const staged = stage.kind === 'staged' ? stage : undefined;
553
+ const insights = staged ? layoutInsightsFromStage(staged) : [];
554
+ const bytes = needsRender && staged
555
+ ? await exportStaged(staged, format, inputs, host)
487
556
  : new Uint8Array(0);
488
- const insights = await collectMcpLayoutInsights({
489
- source,
490
- filePath,
491
- today,
492
- theme: args.theme ?? 'light',
493
- width: args.width,
494
- locale: 'en-US',
495
- readFile: host.readSource,
496
- doc: blocked.doc,
497
- });
498
557
  const previewPayload = {
499
558
  source,
500
- theme: args.theme,
501
- now: args.now,
502
- width: args.width,
559
+ theme,
560
+ now: previewNow(args, options.today),
561
+ width: options.width,
562
+ nonWorking: options.nonWorking,
503
563
  locale: 'en-US',
504
564
  };
505
565
  const reviewBlocks = args.review === true
506
- ? await buildReviewContentBlocks(source, format, bytes, inputs, host)
566
+ ? await buildReviewContentBlocks(blocked.doc, source, format, bytes, inputs, host)
507
567
  : [];
508
568
  const insightHintBlocks = insights.length > 0 ? [{ type: 'text', text: LAYOUT_INSIGHT_HINT }] : [];
509
569
  const insightsField = insights.length > 0 ? { insights } : {};
510
570
  if (args.output) {
511
571
  try {
512
- const outAbs = resolveAndGuard(args.output, allowedRoot);
572
+ const outAbs = resolveAndGuard(args.output, await workspaceRoots());
513
573
  await fs.mkdir(path.dirname(outAbs), { recursive: true });
514
574
  await fs.writeFile(outAbs, bytes);
515
575
  const structured = {
@@ -602,10 +662,15 @@ export function createMcpServer(opts = {}) {
602
662
  'To choose a filename, pass a bare filename (e.g. "roadmap.pdf") — it is saved into the configured folder. ' +
603
663
  'Never pass a virtual or sandbox path such as /home/claude/…, /mnt/user-data/…, or any path ' +
604
664
  'outside the configured output folder — those do not exist on the host filesystem.'),
605
- now: z.string().optional().describe('Now-line date as YYYY-MM-DD (UTC).'),
606
- theme: z.enum(['light', 'dark', 'grayscale']).optional(),
665
+ now: nowSchema,
666
+ timezone: timezoneSchema,
667
+ theme: themeSchema,
668
+ nonWorking: nonWorkingSchema,
607
669
  scale: z.number().optional().describe('PNG scale factor.'),
608
- pageSize: z.string().optional().describe('PDF page size.'),
670
+ pageSize: z
671
+ .string()
672
+ .optional()
673
+ .describe('PDF page size: a preset name, "content", or <W>x<H><unit> (e.g. 8.5x11in). Validated for every format.'),
609
674
  orientation: z
610
675
  .enum(['portrait', 'landscape', 'auto'])
611
676
  .optional()
@@ -631,10 +696,17 @@ export function createMcpServer(opts = {}) {
631
696
  // Only the filesystem touchpoints (reading the input, writing the
632
697
  // output) map to structured NL.MCP.* errors; a kernel export fault
633
698
  // bubbles unchanged so it is never mislabeled as a path/IO failure.
699
+ let options;
700
+ try {
701
+ ({ options } = resolveMcpOptions(args));
702
+ }
703
+ catch (err) {
704
+ return handleToolError(err);
705
+ }
634
706
  let source;
635
707
  let filePath;
636
708
  try {
637
- ({ source, filePath } = await sourceAndPath(args, allowedRoot));
709
+ ({ source, filePath } = await sourceAndPath(args, await workspaceRoots()));
638
710
  }
639
711
  catch (err) {
640
712
  return handleToolError(err, args.path);
@@ -643,14 +715,14 @@ export function createMcpServer(opts = {}) {
643
715
  if (!blocked.ok)
644
716
  return blocked.response;
645
717
  const format = args.format;
646
- const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
647
718
  const inputs = {
648
719
  sourcePath: filePath,
649
- today,
720
+ today: options.today,
650
721
  locale: 'en-US',
651
- theme: args.theme ?? 'light',
722
+ theme: options.theme,
723
+ nonWorking: options.nonWorking,
652
724
  pngScale: args.scale,
653
- pageSize: args.pageSize,
725
+ pageSize: options.pageSize,
654
726
  orientation: args.orientation,
655
727
  marginPt: args.marginPt,
656
728
  msprojStart: args.msprojStart,
@@ -660,7 +732,16 @@ export function createMcpServer(opts = {}) {
660
732
  inputs.fonts = { sans: result.sans, mono: result.mono };
661
733
  }
662
734
  const host = createNodeHostEnv(filePath);
663
- const bytes = await exportDocument(source, format, inputs, host);
735
+ let bytes;
736
+ try {
737
+ const staged = acceptStagedRoadmap(await stageMcpRoadmap(blocked.doc, source, inputs, host), inputs);
738
+ bytes = await exportStaged(staged, format, inputs, host);
739
+ }
740
+ catch (err) {
741
+ if (!(err instanceof IncludeResolveError))
742
+ throw err;
743
+ return includeDiagnosticsResponse(blocked.doc, filePath, routedResolveDiagnosticsToMcp(err.diagnostics));
744
+ }
664
745
  const BINARY_FORMATS = new Set(['png', 'pdf', 'xlsx']);
665
746
  const isBinary = BINARY_FORMATS.has(format);
666
747
  // Default filename: <roadmap-id>.<ext>, used when no output path is given.
@@ -713,11 +794,12 @@ export function createMcpServer(opts = {}) {
713
794
  }
714
795
  }
715
796
  // Write to disk if needed.
716
- const outTarget = args.output ?? path.join(allowedRoot, defaultFilename);
797
+ const roots = await workspaceRoots();
798
+ const outTarget = args.output ?? path.join(roots.primary, defaultFilename);
717
799
  let writtenPath;
718
800
  if (doWrite) {
719
801
  try {
720
- const outAbs = resolveAndGuard(outTarget, allowedRoot);
802
+ const outAbs = resolveAndGuard(outTarget, roots);
721
803
  await fs.mkdir(path.dirname(outAbs), { recursive: true });
722
804
  await fs.writeFile(outAbs, bytes);
723
805
  writtenPath = outAbs;
@@ -785,7 +867,7 @@ export function createMcpServer(opts = {}) {
785
867
  let source;
786
868
  let filePath;
787
869
  try {
788
- ({ source, filePath } = await sourceAndPath(args, allowedRoot));
870
+ ({ source, filePath } = await sourceAndPath(args, await workspaceRoots()));
789
871
  }
790
872
  catch (err) {
791
873
  return handleToolError(err, args.path);
@@ -822,11 +904,14 @@ export function createMcpServer(opts = {}) {
822
904
  }, async (args) => {
823
905
  try {
824
906
  if (args.to === 'json') {
825
- const { source, filePath } = await sourceAndPath(args, allowedRoot);
907
+ const { source, filePath } = await sourceAndPath(args, await workspaceRoots());
826
908
  const host = createNodeHostEnv(filePath);
909
+ // The JSON AST does not depend on today's date, so convert
910
+ // takes no `now`; the kernel still wants a concrete one.
911
+ const { options } = resolveMcpOptions({});
827
912
  const jsonBytes = await exportDocument(source, 'json', {
828
913
  sourcePath: filePath,
829
- today: todayUtc(),
914
+ today: options.today,
830
915
  locale: 'en-US',
831
916
  theme: 'light',
832
917
  }, host);
@@ -840,7 +925,7 @@ export function createMcpServer(opts = {}) {
840
925
  // to: 'nowline' — input is a JSON AST string
841
926
  const jsonSource = args.source ??
842
927
  (args.path
843
- ? await fs.readFile(resolveAndGuard(args.path, allowedRoot), 'utf-8')
928
+ ? await fs.readFile(resolveAndGuard(args.path, await workspaceRoots()), 'utf-8')
844
929
  : null);
845
930
  if (!jsonSource) {
846
931
  throw new InputRequiredError('At least one of `source` or `path` is required.');
@@ -878,7 +963,7 @@ export function createMcpServer(opts = {}) {
878
963
  });
879
964
  // ---- list-themes --------------------------------------------------------
880
965
  server.registerTool('list-themes', {
881
- description: 'List supported color themes: light, dark, grayscale.',
966
+ description: `List supported color themes: ${THEME_NAMES.join(', ')}.`,
882
967
  inputSchema: z.object({}),
883
968
  outputSchema: ListItemsOutputSchema,
884
969
  annotations: readOnlyTool('List Themes'),
@@ -1012,7 +1097,7 @@ export function createMcpServer(opts = {}) {
1012
1097
  };
1013
1098
  });
1014
1099
  server.registerTool('schema', {
1015
- description: 'Return the structured Nowline DSL key vocabulary (directive keys, entity types, item properties).',
1100
+ description: 'Return the structured Nowline DSL key vocabulary (directive keys, entity types, item properties, config keywords, calendar declaration keys).',
1016
1101
  inputSchema: z.object({}),
1017
1102
  outputSchema: SchemaOutputSchema,
1018
1103
  annotations: readOnlyTool('View Roadmap Schema'),
@@ -1021,6 +1106,8 @@ export function createMcpServer(opts = {}) {
1021
1106
  directiveKeys: [...SCHEMA_VOCABULARY.directiveKeys],
1022
1107
  entityTypes: [...SCHEMA_VOCABULARY.entityTypes],
1023
1108
  itemPropertyKeys: [...SCHEMA_VOCABULARY.itemPropertyKeys],
1109
+ configKeywords: [...SCHEMA_VOCABULARY.configKeywords],
1110
+ calendarDeclarationKeys: [...SCHEMA_VOCABULARY.calendarDeclarationKeys],
1024
1111
  };
1025
1112
  return {
1026
1113
  content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
@@ -1068,7 +1155,7 @@ function buildExportInlineContentBlocks(format, bytes, showHint = true, rootConf
1068
1155
  }
1069
1156
  return blocks;
1070
1157
  }
1071
- async function buildReviewContentBlocks(source, format, artifactBytes, inputs, host) {
1158
+ async function buildReviewContentBlocks(doc, source, format, artifactBytes, inputs, host) {
1072
1159
  const blocks = [
1073
1160
  {
1074
1161
  type: 'text',
@@ -1091,7 +1178,10 @@ async function buildReviewContentBlocks(source, format, artifactBytes, inputs, h
1091
1178
  const fonts = await resolveFonts({ headless: true });
1092
1179
  reviewInputs.fonts = { sans: fonts.sans, mono: fonts.mono };
1093
1180
  }
1094
- inspectionBytes = await exportDocument(source, 'png', reviewInputs, host);
1181
+ // A narrower review width is a different layout, so it stages again
1182
+ // (the parsed document is reused).
1183
+ const reviewStage = acceptStagedRoadmap(await stageMcpRoadmap(doc, source, reviewInputs, host), reviewInputs);
1184
+ inspectionBytes = await exportStaged(reviewStage, 'png', reviewInputs, host);
1095
1185
  }
1096
1186
  if (format !== 'png' || inspectionBytes.byteLength !== artifactBytes.byteLength) {
1097
1187
  blocks.push({