@nowline/mcp 0.8.0 → 0.8.2

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.
package/dist/server.js CHANGED
@@ -16,7 +16,7 @@ import { buildShareLink } from '@nowline/share-link';
16
16
  import { z } from 'zod';
17
17
  import { NOWLINE_MCP_ICONS } from './branding.js';
18
18
  import { CAPABILITIES } from './capabilities.js';
19
- import { buildDocument, collectMcpDiagnostics, collectMcpLayoutInsights, DEFAULT_RENDER_WIDTH, diagnosticsErrorBlock, LAYOUT_INSIGHT_HINT, REVIEW_MAX_WIDTH, toolDescriptionWithSyntax, } from './diagnostics.js';
19
+ import { buildDocument, collectMcpDiagnostics, collectMcpLayoutInsights, DEFAULT_RENDER_WIDTH, diagnosticsErrorBlock, handleToolError, InputRequiredError, LAYOUT_INSIGHT_HINT, PathOutsideRootError, REVIEW_MAX_WIDTH, toolDescriptionWithSyntax, } from './diagnostics.js';
20
20
  import { CONVERSIONS_GUIDE, EXAMPLES, REFERENCE_MAN_PAGE, } from './generated/resources.js';
21
21
  import { PREVIEW_HTML } from './generated/ui-bundle.js';
22
22
  import { registerPrompts } from './prompts.js';
@@ -59,12 +59,29 @@ function leanPreviewBlock(payload) {
59
59
  }),
60
60
  };
61
61
  }
62
+ // ---- Tool annotation presets (Anthropic Software Directory Policy § 5.E) ---
63
+ function readOnlyTool(title) {
64
+ return {
65
+ title,
66
+ readOnlyHint: true,
67
+ idempotentHint: true,
68
+ openWorldHint: false,
69
+ };
70
+ }
71
+ function mutatingTool(title, opts) {
72
+ return {
73
+ title,
74
+ destructiveHint: opts.destructiveHint,
75
+ ...(opts.idempotentHint ? { idempotentHint: true } : {}),
76
+ openWorldHint: false,
77
+ };
78
+ }
62
79
  // ---- Server factory ---------------------------------------------------------
63
80
  function resolveAndGuard(filePath, allowedRoot) {
64
81
  const abs = path.resolve(allowedRoot, filePath);
65
82
  const guard = path.resolve(allowedRoot);
66
83
  if (!abs.startsWith(guard + path.sep) && abs !== guard) {
67
- throw new Error(`Path ${filePath} is outside the allowed root ${allowedRoot}`);
84
+ throw new PathOutsideRootError(filePath, allowedRoot);
68
85
  }
69
86
  return abs;
70
87
  }
@@ -115,7 +132,7 @@ async function sourceAndPath(args, allowedRoot) {
115
132
  const source = args.source ?? (await fs.readFile(abs, 'utf-8'));
116
133
  return { source, filePath: abs };
117
134
  }
118
- throw new Error('At least one of `source` or `path` is required.');
135
+ throw new InputRequiredError('At least one of `source` or `path` is required.');
119
136
  }
120
137
  export function createMcpServer(opts = {}) {
121
138
  const allowedRoot = opts.allowedRoot ?? process.cwd();
@@ -133,6 +150,7 @@ export function createMcpServer(opts = {}) {
133
150
  });
134
151
  // ---- Resources ----------------------------------------------------------
135
152
  server.registerResource('nowline-reference', 'nowline://reference', {
153
+ title: 'Roadmap Reference',
136
154
  description: 'Full DSL reference (nowline.5 man page): syntax, directives, and examples.',
137
155
  mimeType: 'text/plain',
138
156
  }, async () => ({
@@ -141,6 +159,7 @@ export function createMcpServer(opts = {}) {
141
159
  ],
142
160
  }));
143
161
  server.registerResource('nowline-examples', 'nowline://examples', {
162
+ title: 'Roadmap Examples',
144
163
  description: 'Canonical example .nowline files from the official examples/ directory.',
145
164
  mimeType: 'text/plain',
146
165
  }, async () => ({
@@ -151,6 +170,7 @@ export function createMcpServer(opts = {}) {
151
170
  })),
152
171
  }));
153
172
  server.registerResource('nowline-conversions', 'nowline://conversions', {
173
+ title: 'Conversion Guide',
154
174
  description: 'LLM-mediated conversion guide: how to translate Mermaid gantt, MS Project, Excel, Google Sheets timeline, and generic CSV into Nowline DSL.',
155
175
  mimeType: 'text/plain',
156
176
  }, async () => ({
@@ -163,6 +183,7 @@ export function createMcpServer(opts = {}) {
163
183
  ],
164
184
  }));
165
185
  registerAppResource(server, 'nowline-preview', PREVIEW_UI_URI, {
186
+ title: 'Roadmap Preview',
166
187
  description: 'Interactive in-chat roadmap preview (MCP Apps). Hydrates via ontoolresult.',
167
188
  }, async () => ({
168
189
  contents: [
@@ -186,32 +207,41 @@ export function createMcpServer(opts = {}) {
186
207
  .describe('Absolute or relative path to a .nowline file to validate.'),
187
208
  }),
188
209
  outputSchema: ValidateOutputSchema,
189
- annotations: { readOnlyHint: true, idempotentHint: true },
210
+ annotations: readOnlyTool('Validate Roadmap'),
190
211
  }, async (args) => {
191
- const { source, filePath } = await sourceAndPath(args, allowedRoot);
192
- const doc = await buildDocument(source);
193
- const diagnostics = collectMcpDiagnostics(doc, filePath);
194
- const ok = diagnostics.every((d) => d.severity !== 'error');
195
- const insights = ok
196
- ? await collectMcpLayoutInsights({
197
- source,
198
- filePath,
199
- today: todayUtc(),
200
- locale: 'en-US',
201
- readFile: createNodeHostEnv(filePath).readSource,
202
- doc,
203
- })
204
- : [];
205
- const structured = { ok, diagnostics, ...(insights.length > 0 ? { insights } : {}) };
206
- return {
207
- content: [
208
- { type: 'text', text: JSON.stringify(structured, null, 2) },
209
- ...(insights.length > 0
210
- ? [{ type: 'text', text: LAYOUT_INSIGHT_HINT }]
211
- : []),
212
- ],
213
- structuredContent: structured,
214
- };
212
+ try {
213
+ const { source, filePath } = await sourceAndPath(args, allowedRoot);
214
+ const doc = await buildDocument(source);
215
+ const diagnostics = collectMcpDiagnostics(doc, filePath);
216
+ const ok = diagnostics.every((d) => d.severity !== 'error');
217
+ const insights = ok
218
+ ? await collectMcpLayoutInsights({
219
+ source,
220
+ filePath,
221
+ today: todayUtc(),
222
+ locale: 'en-US',
223
+ readFile: createNodeHostEnv(filePath).readSource,
224
+ doc,
225
+ })
226
+ : [];
227
+ const structured = {
228
+ ok,
229
+ diagnostics,
230
+ ...(insights.length > 0 ? { insights } : {}),
231
+ };
232
+ return {
233
+ content: [
234
+ { type: 'text', text: JSON.stringify(structured, null, 2) },
235
+ ...(insights.length > 0
236
+ ? [{ type: 'text', text: LAYOUT_INSIGHT_HINT }]
237
+ : []),
238
+ ],
239
+ structuredContent: structured,
240
+ };
241
+ }
242
+ catch (err) {
243
+ return handleToolError(err, args.path);
244
+ }
215
245
  });
216
246
  // ---- read ---------------------------------------------------------------
217
247
  server.registerTool('read', {
@@ -222,15 +252,20 @@ export function createMcpServer(opts = {}) {
222
252
  .describe('Absolute or relative path to the .nowline file to read.'),
223
253
  }),
224
254
  outputSchema: ReadOutputSchema,
225
- annotations: { readOnlyHint: true, idempotentHint: true },
255
+ annotations: readOnlyTool('Read Roadmap'),
226
256
  }, async (args) => {
227
- const abs = resolveAndGuard(args.path, allowedRoot);
228
- const source = await fs.readFile(abs, 'utf-8');
229
- const structured = { path: abs, source };
230
- return {
231
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
232
- structuredContent: structured,
233
- };
257
+ try {
258
+ const abs = resolveAndGuard(args.path, allowedRoot);
259
+ const source = await fs.readFile(abs, 'utf-8');
260
+ const structured = { path: abs, source };
261
+ return {
262
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
263
+ structuredContent: structured,
264
+ };
265
+ }
266
+ catch (err) {
267
+ return handleToolError(err, args.path);
268
+ }
234
269
  });
235
270
  // ---- create -------------------------------------------------------------
236
271
  server.registerTool('create', {
@@ -241,19 +276,27 @@ export function createMcpServer(opts = {}) {
241
276
  }),
242
277
  outputSchema: CreateOutputSchema,
243
278
  // Overwrites silently → destructive; same source always produces same file → idempotent.
244
- annotations: { destructiveHint: true, idempotentHint: true },
279
+ annotations: mutatingTool('Create Roadmap', {
280
+ destructiveHint: true,
281
+ idempotentHint: true,
282
+ }),
245
283
  }, async (args) => {
246
- const abs = resolveAndGuard(args.path, allowedRoot);
247
- const blocked = await diagnosticsErrorBlock(args.source, abs);
248
- if (!blocked.ok)
249
- return blocked.response;
250
- await fs.mkdir(path.dirname(abs), { recursive: true });
251
- await fs.writeFile(abs, args.source, 'utf-8');
252
- const structured = { ok: true, path: abs };
253
- return {
254
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
255
- structuredContent: structured,
256
- };
284
+ try {
285
+ const abs = resolveAndGuard(args.path, allowedRoot);
286
+ const blocked = await diagnosticsErrorBlock(args.source, abs);
287
+ if (!blocked.ok)
288
+ return blocked.response;
289
+ await fs.mkdir(path.dirname(abs), { recursive: true });
290
+ await fs.writeFile(abs, args.source, 'utf-8');
291
+ const structured = { ok: true, path: abs };
292
+ return {
293
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
294
+ structuredContent: structured,
295
+ };
296
+ }
297
+ catch (err) {
298
+ return handleToolError(err, args.path);
299
+ }
257
300
  });
258
301
  // ---- update -------------------------------------------------------------
259
302
  server.registerTool('update', {
@@ -265,18 +308,26 @@ export function createMcpServer(opts = {}) {
265
308
  source: z.string().describe('The new .nowline source text.'),
266
309
  }),
267
310
  outputSchema: UpdateOutputSchema,
268
- annotations: { idempotentHint: true },
311
+ annotations: mutatingTool('Update Roadmap', {
312
+ destructiveHint: true,
313
+ idempotentHint: true,
314
+ }),
269
315
  }, async (args) => {
270
- const abs = resolveAndGuard(args.path, allowedRoot);
271
- const blocked = await diagnosticsErrorBlock(args.source, abs);
272
- if (!blocked.ok)
273
- return blocked.response;
274
- await fs.writeFile(abs, args.source, 'utf-8');
275
- const structured = { ok: true, path: abs };
276
- return {
277
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
278
- structuredContent: structured,
279
- };
316
+ try {
317
+ const abs = resolveAndGuard(args.path, allowedRoot);
318
+ const blocked = await diagnosticsErrorBlock(args.source, abs);
319
+ if (!blocked.ok)
320
+ return blocked.response;
321
+ await fs.writeFile(abs, args.source, 'utf-8');
322
+ const structured = { ok: true, path: abs };
323
+ return {
324
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
325
+ structuredContent: structured,
326
+ };
327
+ }
328
+ catch (err) {
329
+ return handleToolError(err, args.path);
330
+ }
280
331
  });
281
332
  // ---- delete -------------------------------------------------------------
282
333
  server.registerTool('delete', {
@@ -287,15 +338,20 @@ export function createMcpServer(opts = {}) {
287
338
  .describe('Absolute or relative path of the .nowline file to delete.'),
288
339
  }),
289
340
  outputSchema: DeleteOutputSchema,
290
- annotations: { destructiveHint: true },
341
+ annotations: mutatingTool('Delete Roadmap', { destructiveHint: true }),
291
342
  }, async (args) => {
292
- const abs = resolveAndGuard(args.path, allowedRoot);
293
- await fs.unlink(abs);
294
- const structured = { path: abs };
295
- return {
296
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
297
- structuredContent: structured,
298
- };
343
+ try {
344
+ const abs = resolveAndGuard(args.path, allowedRoot);
345
+ await fs.unlink(abs);
346
+ const structured = { path: abs };
347
+ return {
348
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
349
+ structuredContent: structured,
350
+ };
351
+ }
352
+ catch (err) {
353
+ return handleToolError(err, args.path);
354
+ }
299
355
  });
300
356
  // ---- list ---------------------------------------------------------------
301
357
  server.registerTool('list', {
@@ -311,16 +367,23 @@ export function createMcpServer(opts = {}) {
311
367
  .describe('Whether to scan subdirectories. Defaults to false.'),
312
368
  }),
313
369
  outputSchema: ListOutputSchema,
314
- annotations: { readOnlyHint: true, idempotentHint: true },
370
+ annotations: readOnlyTool('List Roadmaps'),
315
371
  }, async (args) => {
316
- const dir = args.directory ? resolveAndGuard(args.directory, allowedRoot) : allowedRoot;
317
- const recursive = args.recursive ?? false;
318
- const paths = await listNowlineFiles(dir, recursive);
319
- const structured = { paths };
320
- return {
321
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
322
- structuredContent: structured,
323
- };
372
+ try {
373
+ const dir = args.directory
374
+ ? resolveAndGuard(args.directory, allowedRoot)
375
+ : allowedRoot;
376
+ const recursive = args.recursive ?? false;
377
+ const paths = await listNowlineFiles(dir, recursive);
378
+ const structured = { paths };
379
+ return {
380
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
381
+ structuredContent: structured,
382
+ };
383
+ }
384
+ catch (err) {
385
+ return handleToolError(err, args.directory);
386
+ }
324
387
  });
325
388
  // ---- render -------------------------------------------------------------
326
389
  registerAppTool(server, 'render', {
@@ -371,13 +434,23 @@ export function createMcpServer(opts = {}) {
371
434
  .describe('When true, force the in-chat MCP Apps preview. On MCP Apps hosts the preview auto-renders via _meta.ui without this flag.'),
372
435
  }),
373
436
  outputSchema: RenderOutputSchema,
374
- annotations: { readOnlyHint: true, idempotentHint: true },
437
+ annotations: readOnlyTool('Render Roadmap'),
375
438
  _meta: {
376
439
  ui: { resourceUri: PREVIEW_UI_URI },
377
440
  'openai/outputTemplate': PREVIEW_UI_URI,
378
441
  },
379
442
  }, async (args) => {
380
- const { source, filePath } = await sourceAndPath(args, allowedRoot);
443
+ // Only the filesystem touchpoints (reading the input, writing the
444
+ // output) map to structured NL.MCP.* errors; a kernel render fault
445
+ // bubbles unchanged so it is never mislabeled as a path/IO failure.
446
+ let source;
447
+ let filePath;
448
+ try {
449
+ ({ source, filePath } = await sourceAndPath(args, allowedRoot));
450
+ }
451
+ catch (err) {
452
+ return handleToolError(err, args.path);
453
+ }
381
454
  const blocked = await diagnosticsErrorBlock(source, filePath);
382
455
  if (!blocked.ok)
383
456
  return blocked.response;
@@ -429,43 +502,53 @@ export function createMcpServer(opts = {}) {
429
502
  const insightHintBlocks = insights.length > 0 ? [{ type: 'text', text: LAYOUT_INSIGHT_HINT }] : [];
430
503
  const insightsField = insights.length > 0 ? { insights } : {};
431
504
  if (args.output) {
432
- const outAbs = resolveAndGuard(args.output, allowedRoot);
433
- await fs.mkdir(path.dirname(outAbs), { recursive: true });
434
- await fs.writeFile(outAbs, bytes);
505
+ try {
506
+ const outAbs = resolveAndGuard(args.output, allowedRoot);
507
+ await fs.mkdir(path.dirname(outAbs), { recursive: true });
508
+ await fs.writeFile(outAbs, bytes);
509
+ const structured = {
510
+ format,
511
+ path: outAbs,
512
+ bytes: bytes.byteLength,
513
+ shareUrl,
514
+ ...insightsField,
515
+ };
516
+ return {
517
+ content: [
518
+ ...(appActive ? [leanPreviewBlock(previewPayload)] : []),
519
+ { type: 'text', text: JSON.stringify(structured, null, 2) },
520
+ ...insightHintBlocks,
521
+ ...reviewBlocks,
522
+ ],
523
+ structuredContent: structured,
524
+ };
525
+ }
526
+ catch (err) {
527
+ return handleToolError(err, args.output);
528
+ }
529
+ }
530
+ if (appActive) {
435
531
  const structured = {
436
532
  format,
437
- path: outAbs,
438
- bytes: bytes.byteLength,
439
533
  shareUrl,
440
534
  ...insightsField,
441
535
  };
442
536
  return {
443
537
  content: [
444
- ...(appActive ? [leanPreviewBlock(previewPayload)] : []),
445
- { type: 'text', text: JSON.stringify(structured, null, 2) },
538
+ leanPreviewBlock(previewPayload),
446
539
  ...insightHintBlocks,
447
540
  ...reviewBlocks,
448
541
  ],
449
542
  structuredContent: structured,
450
543
  };
451
544
  }
452
- if (appActive) {
545
+ if (format === 'png') {
453
546
  const structured = {
454
547
  format,
548
+ bytes: bytes.byteLength,
455
549
  shareUrl,
456
550
  ...insightsField,
457
551
  };
458
- return {
459
- content: [
460
- leanPreviewBlock(previewPayload),
461
- ...insightHintBlocks,
462
- ...reviewBlocks,
463
- ],
464
- structuredContent: structured,
465
- };
466
- }
467
- if (format === 'png') {
468
- const structured = { format, bytes: bytes.byteLength, shareUrl, ...insightsField };
469
552
  return {
470
553
  content: [
471
554
  {
@@ -531,9 +614,19 @@ export function createMcpServer(opts = {}) {
531
614
  .describe('When true, include a shareUrl pointing to https://free.nowline.io/open.'),
532
615
  }),
533
616
  outputSchema: ExportOutputSchema,
534
- annotations: { readOnlyHint: true, idempotentHint: true },
617
+ annotations: readOnlyTool('Export Roadmap'),
535
618
  }, async (args) => {
536
- const { source, filePath } = await sourceAndPath(args, allowedRoot);
619
+ // Only the filesystem touchpoints (reading the input, writing the
620
+ // output) map to structured NL.MCP.* errors; a kernel export fault
621
+ // bubbles unchanged so it is never mislabeled as a path/IO failure.
622
+ let source;
623
+ let filePath;
624
+ try {
625
+ ({ source, filePath } = await sourceAndPath(args, allowedRoot));
626
+ }
627
+ catch (err) {
628
+ return handleToolError(err, args.path);
629
+ }
537
630
  const blocked = await diagnosticsErrorBlock(source, filePath);
538
631
  if (!blocked.ok)
539
632
  return blocked.response;
@@ -562,14 +655,19 @@ export function createMcpServer(opts = {}) {
562
655
  const BINARY_FORMATS = new Set(['png', 'pdf', 'xlsx']);
563
656
  const isBinary = BINARY_FORMATS.has(format);
564
657
  if (args.output) {
565
- const outAbs = resolveAndGuard(args.output, allowedRoot);
566
- await fs.mkdir(path.dirname(outAbs), { recursive: true });
567
- await fs.writeFile(outAbs, bytes);
568
- const structured = { format, path: outAbs, bytes: bytes.byteLength, shareUrl };
569
- return {
570
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
571
- structuredContent: structured,
572
- };
658
+ try {
659
+ const outAbs = resolveAndGuard(args.output, allowedRoot);
660
+ await fs.mkdir(path.dirname(outAbs), { recursive: true });
661
+ await fs.writeFile(outAbs, bytes);
662
+ const structured = { format, path: outAbs, bytes: bytes.byteLength, shareUrl };
663
+ return {
664
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
665
+ structuredContent: structured,
666
+ };
667
+ }
668
+ catch (err) {
669
+ return handleToolError(err, args.output);
670
+ }
573
671
  }
574
672
  if (isBinary) {
575
673
  const mimeMap = {
@@ -612,46 +710,51 @@ export function createMcpServer(opts = {}) {
612
710
  .describe('"json" — serialize .nowline text to JSON AST. "nowline" — pretty-print a JSON AST back to .nowline source.'),
613
711
  }),
614
712
  outputSchema: ConvertOutputSchema,
615
- annotations: { readOnlyHint: true, idempotentHint: true },
713
+ annotations: readOnlyTool('Convert Roadmap'),
616
714
  }, async (args) => {
617
- if (args.to === 'json') {
618
- const { source, filePath } = await sourceAndPath(args, allowedRoot);
619
- const host = createNodeHostEnv(filePath);
620
- const jsonBytes = await exportDocument(source, 'json', {
621
- sourcePath: filePath,
622
- today: todayUtc(),
623
- locale: 'en-US',
624
- theme: 'light',
625
- }, host);
626
- const result = new TextDecoder('utf-8').decode(jsonBytes);
627
- const structured = { to: 'json', result };
715
+ try {
716
+ if (args.to === 'json') {
717
+ const { source, filePath } = await sourceAndPath(args, allowedRoot);
718
+ const host = createNodeHostEnv(filePath);
719
+ const jsonBytes = await exportDocument(source, 'json', {
720
+ sourcePath: filePath,
721
+ today: todayUtc(),
722
+ locale: 'en-US',
723
+ theme: 'light',
724
+ }, host);
725
+ const result = new TextDecoder('utf-8').decode(jsonBytes);
726
+ const structured = { to: 'json', result };
727
+ return {
728
+ content: [{ type: 'text', text: result }],
729
+ structuredContent: structured,
730
+ };
731
+ }
732
+ // to: 'nowline' — input is a JSON AST string
733
+ const jsonSource = args.source ??
734
+ (args.path
735
+ ? await fs.readFile(resolveAndGuard(args.path, allowedRoot), 'utf-8')
736
+ : null);
737
+ if (!jsonSource) {
738
+ throw new InputRequiredError('At least one of `source` or `path` is required.');
739
+ }
740
+ const { ast } = parseNowlineJson(jsonSource, args.path ?? 'input.json');
741
+ const result = printNowlineFile(ast);
742
+ const structured = { to: 'nowline', result };
628
743
  return {
629
744
  content: [{ type: 'text', text: result }],
630
745
  structuredContent: structured,
631
746
  };
632
747
  }
633
- // to: 'nowline' — input is a JSON AST string
634
- const jsonSource = args.source ??
635
- (args.path
636
- ? await fs.readFile(resolveAndGuard(args.path, allowedRoot), 'utf-8')
637
- : null);
638
- if (!jsonSource) {
639
- throw new Error('At least one of `source` or `path` is required.');
748
+ catch (err) {
749
+ return handleToolError(err, args.path);
640
750
  }
641
- const { ast } = parseNowlineJson(jsonSource, args.path ?? 'input.json');
642
- const result = printNowlineFile(ast);
643
- const structured = { to: 'nowline', result };
644
- return {
645
- content: [{ type: 'text', text: result }],
646
- structuredContent: structured,
647
- };
648
751
  });
649
752
  // ---- capabilities -------------------------------------------------------
650
753
  server.registerTool('capabilities', {
651
754
  description: 'Return all supported themes, icons, locales, export formats, and template names in a single response.',
652
755
  inputSchema: z.object({}),
653
756
  outputSchema: CapabilitiesOutputSchema,
654
- annotations: { readOnlyHint: true, idempotentHint: true },
757
+ annotations: readOnlyTool('View Roadmap Capabilities'),
655
758
  }, async () => {
656
759
  const structured = {
657
760
  themes: [...CAPABILITIES.themes],
@@ -670,7 +773,7 @@ export function createMcpServer(opts = {}) {
670
773
  description: 'List supported color themes: light, dark, grayscale.',
671
774
  inputSchema: z.object({}),
672
775
  outputSchema: ListItemsOutputSchema,
673
- annotations: { readOnlyHint: true, idempotentHint: true },
776
+ annotations: readOnlyTool('List Themes'),
674
777
  }, async () => {
675
778
  const structured = { items: [...CAPABILITIES.themes] };
676
779
  return {
@@ -683,7 +786,7 @@ export function createMcpServer(opts = {}) {
683
786
  description: 'List built-in capacity-icon names usable in the `capacity-icon:` style property.',
684
787
  inputSchema: z.object({}),
685
788
  outputSchema: ListItemsOutputSchema,
686
- annotations: { readOnlyHint: true, idempotentHint: true },
789
+ annotations: readOnlyTool('List Icons'),
687
790
  }, async () => {
688
791
  const structured = { items: [...CAPABILITIES.icons] };
689
792
  return {
@@ -696,7 +799,7 @@ export function createMcpServer(opts = {}) {
696
799
  description: 'List supported BCP-47 locale tags.',
697
800
  inputSchema: z.object({}),
698
801
  outputSchema: ListItemsOutputSchema,
699
- annotations: { readOnlyHint: true, idempotentHint: true },
802
+ annotations: readOnlyTool('List Locales'),
700
803
  }, async () => {
701
804
  const structured = { items: [...CAPABILITIES.locales] };
702
805
  return {
@@ -709,7 +812,7 @@ export function createMcpServer(opts = {}) {
709
812
  description: 'List all supported export formats (svg, png, pdf, html, mermaid, xlsx, msproj, json).',
710
813
  inputSchema: z.object({}),
711
814
  outputSchema: ListItemsOutputSchema,
712
- annotations: { readOnlyHint: true, idempotentHint: true },
815
+ annotations: readOnlyTool('List Export Formats'),
713
816
  }, async () => {
714
817
  const structured = { items: [...CAPABILITIES.formats] };
715
818
  return {
@@ -722,7 +825,7 @@ export function createMcpServer(opts = {}) {
722
825
  description: 'List built-in template names usable with `nowline --init --template`.',
723
826
  inputSchema: z.object({}),
724
827
  outputSchema: ListItemsOutputSchema,
725
- annotations: { readOnlyHint: true, idempotentHint: true },
828
+ annotations: readOnlyTool('List Templates'),
726
829
  }, async () => {
727
830
  const structured = { items: [...CAPABILITIES.templates] };
728
831
  return {
@@ -740,7 +843,7 @@ export function createMcpServer(opts = {}) {
740
843
  .describe('Reference format. Defaults to condensed.'),
741
844
  }),
742
845
  outputSchema: ReferenceOutputSchema,
743
- annotations: { readOnlyHint: true, idempotentHint: true },
846
+ annotations: readOnlyTool('View Roadmap Reference'),
744
847
  }, async (args) => {
745
848
  const format = args.format ?? 'condensed';
746
849
  const text = format === 'full' ? REFERENCE_MAN_PAGE : REFERENCE_CHEATSHEET;
@@ -759,7 +862,7 @@ export function createMcpServer(opts = {}) {
759
862
  .describe('Example name. Omit for the catalog plus minimal inline.'),
760
863
  }),
761
864
  outputSchema: ExamplesOutputSchema,
762
- annotations: { readOnlyHint: true, idempotentHint: true },
865
+ annotations: readOnlyTool('View Roadmap Examples'),
763
866
  }, async (args) => {
764
867
  const exampleNames = EXAMPLES.map((e) => exampleShortName(e.name));
765
868
  if (args.name) {
@@ -804,7 +907,7 @@ export function createMcpServer(opts = {}) {
804
907
  description: 'Return the structured Nowline DSL key vocabulary (directive keys, entity types, item properties).',
805
908
  inputSchema: z.object({}),
806
909
  outputSchema: SchemaOutputSchema,
807
- annotations: { readOnlyHint: true, idempotentHint: true },
910
+ annotations: readOnlyTool('View Roadmap Schema'),
808
911
  }, async () => {
809
912
  const structured = {
810
913
  directiveKeys: [...SCHEMA_VOCABULARY.directiveKeys],