@nowline/mcp 0.7.0 → 0.8.1

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 (48) 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 +85 -0
  6. package/dist/diagnostics.d.ts.map +1 -0
  7. package/dist/diagnostics.js +172 -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/prompts.d.ts.map +1 -1
  14. package/dist/prompts.js +3 -0
  15. package/dist/prompts.js.map +1 -1
  16. package/dist/reference-cheatsheet.d.ts +2 -0
  17. package/dist/reference-cheatsheet.d.ts.map +1 -0
  18. package/dist/reference-cheatsheet.js +47 -0
  19. package/dist/reference-cheatsheet.js.map +1 -0
  20. package/dist/schema-vocab.d.ts +6 -0
  21. package/dist/schema-vocab.d.ts.map +1 -0
  22. package/dist/schema-vocab.js +55 -0
  23. package/dist/schema-vocab.js.map +1 -0
  24. package/dist/schemas.d.ts +52 -0
  25. package/dist/schemas.d.ts.map +1 -1
  26. package/dist/schemas.js +24 -1
  27. package/dist/schemas.js.map +1 -1
  28. package/dist/server.d.ts +2 -0
  29. package/dist/server.d.ts.map +1 -1
  30. package/dist/server.js +507 -257
  31. package/dist/server.js.map +1 -1
  32. package/dist/ui/entry.js +148 -110
  33. package/dist/ui/entry.js.map +1 -1
  34. package/dist/ui/payload.d.ts +30 -0
  35. package/dist/ui/payload.d.ts.map +1 -0
  36. package/dist/ui/payload.js +49 -0
  37. package/dist/ui/payload.js.map +1 -0
  38. package/package.json +11 -7
  39. package/src/branding.ts +26 -0
  40. package/src/diagnostics.ts +248 -0
  41. package/src/generated/ui-bundle.ts +5 -3
  42. package/src/prompts.ts +3 -0
  43. package/src/reference-cheatsheet.ts +47 -0
  44. package/src/schema-vocab.ts +55 -0
  45. package/src/schemas.ts +28 -1
  46. package/src/server.ts +615 -296
  47. package/src/ui/entry.ts +167 -122
  48. 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,81 @@ 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
+ handleToolError,
37
+ InputRequiredError,
38
+ LAYOUT_INSIGHT_HINT,
39
+ PathOutsideRootError,
40
+ REVIEW_MAX_WIDTH,
41
+ toolDescriptionWithSyntax,
42
+ } from './diagnostics.js';
43
+ import {
44
+ CONVERSIONS_GUIDE,
45
+ EXAMPLES,
46
+ type ExampleFile,
47
+ REFERENCE_MAN_PAGE,
48
+ } from './generated/resources.js';
49
+ import { PREVIEW_HTML } from './generated/ui-bundle.js';
32
50
  import { registerPrompts } from './prompts.js';
51
+ import { REFERENCE_CHEATSHEET } from './reference-cheatsheet.js';
52
+ import { SCHEMA_VOCABULARY } from './schema-vocab.js';
33
53
  import {
34
54
  CapabilitiesOutputSchema,
35
55
  ConvertOutputSchema,
36
56
  CreateOutputSchema,
37
57
  DeleteOutputSchema,
58
+ ExamplesOutputSchema,
38
59
  ExportOutputSchema,
39
60
  ListItemsOutputSchema,
40
61
  ListOutputSchema,
41
62
  ReadOutputSchema,
63
+ ReferenceOutputSchema,
42
64
  RenderOutputSchema,
65
+ SchemaOutputSchema,
43
66
  UpdateOutputSchema,
44
67
  ValidateOutputSchema,
45
68
  } from './schemas.js';
46
69
 
47
70
  // ---- MCP Apps UI (in-chat live preview) -------------------------------------
48
71
  //
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).
72
+ // Official MCP Apps model (SEP-1865): a pre-declared ui:// resource serves
73
+ // static HTML; the render tool declares _meta.ui.resourceUri; per-call data
74
+ // flows through the ontoolresult handshake. When an MCP Apps host is active,
75
+ // render returns a lean nowline.preview JSON payload (no inline SVG/PNG) so
76
+ // results stay under the host's ~150K inline cap. Non-apps hosts still get
77
+ // the full SVG/PNG inline (graceful degradation).
57
78
 
58
79
  /** SEP-1865 UI extension capability id; also probed under common short keys. */
59
80
  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';
81
+ /** Versioned URI doubles as a cache key — bump suffix on bundle changes. */
82
+ export const PREVIEW_UI_URI = 'ui://nowline/preview-v1';
63
83
 
64
84
  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>
85
+ // SEP-1724 negotiated extensions: canonical id under `extensions`.
86
+ const caps = server.server.getClientCapabilities() as
87
+ | {
88
+ experimental?: Record<string, unknown>;
89
+ extensions?: Record<string, unknown>;
90
+ }
70
91
  | undefined;
71
- if (!experimental) return false;
72
- return Boolean(experimental[MCP_APPS_UI_CAPABILITY] || experimental.ui || experimental.apps);
92
+ if (!caps) return false;
93
+ if (getUiCapability(caps as Parameters<typeof getUiCapability>[0])) return true;
94
+
95
+ // Hosts also advertise under `experimental` or short `ui` / `apps` aliases.
96
+ const buckets = [caps.extensions, caps.experimental].filter(
97
+ (bucket): bucket is Record<string, unknown> => Boolean(bucket),
98
+ );
99
+ return buckets.some((bucket) =>
100
+ Boolean(bucket[MCP_APPS_UI_CAPABILITY] || bucket.ui || bucket.apps),
101
+ );
73
102
  }
74
103
 
75
104
  interface PreviewPayload {
@@ -78,118 +107,49 @@ interface PreviewPayload {
78
107
  now?: string;
79
108
  width?: number;
80
109
  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
110
  }
110
111
 
111
- function previewResourceBlock(payload: PreviewPayload) {
112
+ function leanPreviewBlock(payload: PreviewPayload) {
112
113
  return {
113
- type: 'resource' as const,
114
- resource: {
115
- uri: PREVIEW_UI_URI,
116
- mimeType: PREVIEW_UI_MIME,
117
- text: buildPreviewHtml(payload),
118
- },
114
+ type: 'text' as const,
115
+ text: JSON.stringify({
116
+ kind: 'nowline.preview',
117
+ source: payload.source,
118
+ theme: payload.theme,
119
+ now: payload.now,
120
+ width: payload.width,
121
+ locale: payload.locale,
122
+ }),
119
123
  };
120
124
  }
121
125
 
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
- }
126
+ // ---- Tool annotation presets (Anthropic Software Directory Policy § 5.E) ---
132
127
 
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;
128
+ function readOnlyTool(title: string) {
129
+ return {
130
+ title,
131
+ readOnlyHint: true as const,
132
+ idempotentHint: true as const,
133
+ openWorldHint: false as const,
134
+ };
173
135
  }
174
136
 
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;
137
+ function mutatingTool(title: string, opts: { destructiveHint: boolean; idempotentHint?: boolean }) {
138
+ return {
139
+ title,
140
+ destructiveHint: opts.destructiveHint,
141
+ ...(opts.idempotentHint ? { idempotentHint: true as const } : {}),
142
+ openWorldHint: false as const,
143
+ };
184
144
  }
185
145
 
186
- // ---- Allowed-root enforcement -----------------------------------------------
146
+ // ---- Server factory ---------------------------------------------------------
187
147
 
188
148
  function resolveAndGuard(filePath: string, allowedRoot: string): string {
189
149
  const abs = path.resolve(allowedRoot, filePath);
190
150
  const guard = path.resolve(allowedRoot);
191
151
  if (!abs.startsWith(guard + path.sep) && abs !== guard) {
192
- throw new Error(`Path ${filePath} is outside the allowed root ${allowedRoot}`);
152
+ throw new PathOutsideRootError(filePath, allowedRoot);
193
153
  }
194
154
  return abs;
195
155
  }
@@ -229,6 +189,15 @@ function todayUtc(): Date {
229
189
  return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
230
190
  }
231
191
 
192
+ function exampleShortName(fullName: string): string {
193
+ return fullName.endsWith('.nowline') ? fullName.slice(0, -'.nowline'.length) : fullName;
194
+ }
195
+
196
+ function findExample(name: string): ExampleFile | undefined {
197
+ const withExt = name.endsWith('.nowline') ? name : `${name}.nowline`;
198
+ return EXAMPLES.find((e) => e.name === withExt || e.name === name);
199
+ }
200
+
232
201
  async function sourceAndPath(
233
202
  args: { source?: string; path?: string },
234
203
  allowedRoot: string,
@@ -241,7 +210,7 @@ async function sourceAndPath(
241
210
  const source = args.source ?? (await fs.readFile(abs, 'utf-8'));
242
211
  return { source, filePath: abs };
243
212
  }
244
- throw new Error('At least one of `source` or `path` is required.');
213
+ throw new InputRequiredError('At least one of `source` or `path` is required.');
245
214
  }
246
215
 
247
216
  // ---- Server factory ---------------------------------------------------------
@@ -257,10 +226,22 @@ export interface McpServerOptions {
257
226
 
258
227
  export function createMcpServer(opts: McpServerOptions = {}): McpServer {
259
228
  const allowedRoot = opts.allowedRoot ?? process.cwd();
260
- const server = new McpServer({
261
- name: opts.name ?? 'nowline',
262
- version: opts.version ?? '0.6.0',
263
- });
229
+ const server = new McpServer(
230
+ {
231
+ name: opts.name ?? 'nowline',
232
+ version: opts.version ?? '0.6.0',
233
+ icons: [...NOWLINE_MCP_ICONS],
234
+ },
235
+ {
236
+ instructions:
237
+ 'Nowline manages roadmaps written in the .nowline plain-text DSL — NOT JSON or any other ' +
238
+ 'structured format. All `source` parameters expect `.nowline` DSL text (starts with `nowline v1`). ' +
239
+ 'Workflow: 1. call `reference` or `examples` to learn syntax → 2. write `.nowline` → ' +
240
+ '3. call `render` (validates + renders; or `validate` alone) → 4. fix errors keyed on `NL.E####` ' +
241
+ 'and re-render → 5. review returned layout `insights` (what reflowed) → 6. when uncertain, ' +
242
+ 'call `render` with `review:true` for a final visual check. JSON in `convert` is AST conversion only.',
243
+ },
244
+ );
264
245
 
265
246
  // ---- Resources ----------------------------------------------------------
266
247
 
@@ -268,6 +249,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
268
249
  'nowline-reference',
269
250
  'nowline://reference',
270
251
  {
252
+ title: 'Roadmap Reference',
271
253
  description:
272
254
  'Full DSL reference (nowline.5 man page): syntax, directives, and examples.',
273
255
  mimeType: 'text/plain',
@@ -283,6 +265,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
283
265
  'nowline-examples',
284
266
  'nowline://examples',
285
267
  {
268
+ title: 'Roadmap Examples',
286
269
  description: 'Canonical example .nowline files from the official examples/ directory.',
287
270
  mimeType: 'text/plain',
288
271
  },
@@ -299,6 +282,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
299
282
  'nowline-conversions',
300
283
  'nowline://conversions',
301
284
  {
285
+ title: 'Conversion Guide',
302
286
  description:
303
287
  'LLM-mediated conversion guide: how to translate Mermaid gantt, MS Project, Excel, Google Sheets timeline, and generic CSV into Nowline DSL.',
304
288
  mimeType: 'text/plain',
@@ -314,6 +298,26 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
314
298
  }),
315
299
  );
316
300
 
301
+ registerAppResource(
302
+ server,
303
+ 'nowline-preview',
304
+ PREVIEW_UI_URI,
305
+ {
306
+ title: 'Roadmap Preview',
307
+ description:
308
+ 'Interactive in-chat roadmap preview (MCP Apps). Hydrates via ontoolresult.',
309
+ },
310
+ async () => ({
311
+ contents: [
312
+ {
313
+ uri: PREVIEW_UI_URI,
314
+ mimeType: RESOURCE_MIME_TYPE,
315
+ text: PREVIEW_HTML,
316
+ },
317
+ ],
318
+ }),
319
+ );
320
+
317
321
  // ---- Prompts ------------------------------------------------------------
318
322
 
319
323
  registerPrompts(server);
@@ -323,8 +327,9 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
323
327
  server.registerTool(
324
328
  'validate',
325
329
  {
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.',
330
+ description: toolDescriptionWithSyntax(
331
+ 'Parse and validate a .nowline roadmap. Returns ok=true with optional layout insights when valid, or ok=false with structured diagnostics.',
332
+ ),
328
333
  inputSchema: z.object({
329
334
  source: z.string().optional().describe('Inline .nowline source text to validate.'),
330
335
  path: z
@@ -333,18 +338,41 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
333
338
  .describe('Absolute or relative path to a .nowline file to validate.'),
334
339
  }),
335
340
  outputSchema: ValidateOutputSchema,
336
- annotations: { readOnlyHint: true, idempotentHint: true },
341
+ annotations: readOnlyTool('Validate Roadmap'),
337
342
  },
338
343
  async (args) => {
339
- const { source, filePath } = await sourceAndPath(args, allowedRoot);
340
- const doc = await buildDocument(source);
341
- const diagnostics = collectMcpDiagnostics(doc, filePath);
342
- const ok = diagnostics.every((d) => d.severity !== 'error');
343
- const structured = { ok, diagnostics };
344
- return {
345
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
346
- structuredContent: structured,
347
- };
344
+ try {
345
+ const { source, filePath } = await sourceAndPath(args, allowedRoot);
346
+ const doc = await buildDocument(source);
347
+ const diagnostics = collectMcpDiagnostics(doc, filePath);
348
+ const ok = diagnostics.every((d) => d.severity !== 'error');
349
+ const insights = ok
350
+ ? await collectMcpLayoutInsights({
351
+ source,
352
+ filePath,
353
+ today: todayUtc(),
354
+ locale: 'en-US',
355
+ readFile: createNodeHostEnv(filePath).readSource,
356
+ doc,
357
+ })
358
+ : [];
359
+ const structured = {
360
+ ok,
361
+ diagnostics,
362
+ ...(insights.length > 0 ? { insights } : {}),
363
+ };
364
+ return {
365
+ content: [
366
+ { type: 'text', text: JSON.stringify(structured, null, 2) },
367
+ ...(insights.length > 0
368
+ ? [{ type: 'text' as const, text: LAYOUT_INSIGHT_HINT }]
369
+ : []),
370
+ ],
371
+ structuredContent: structured,
372
+ };
373
+ } catch (err) {
374
+ return handleToolError(err, args.path);
375
+ }
348
376
  },
349
377
  );
350
378
 
@@ -360,16 +388,20 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
360
388
  .describe('Absolute or relative path to the .nowline file to read.'),
361
389
  }),
362
390
  outputSchema: ReadOutputSchema,
363
- annotations: { readOnlyHint: true, idempotentHint: true },
391
+ annotations: readOnlyTool('Read Roadmap'),
364
392
  },
365
393
  async (args) => {
366
- const abs = resolveAndGuard(args.path, allowedRoot);
367
- const source = await fs.readFile(abs, 'utf-8');
368
- const structured = { path: abs, source };
369
- return {
370
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
371
- structuredContent: structured,
372
- };
394
+ try {
395
+ const abs = resolveAndGuard(args.path, allowedRoot);
396
+ const source = await fs.readFile(abs, 'utf-8');
397
+ const structured = { path: abs, source };
398
+ return {
399
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
400
+ structuredContent: structured,
401
+ };
402
+ } catch (err) {
403
+ return handleToolError(err, args.path);
404
+ }
373
405
  },
374
406
  );
375
407
 
@@ -378,39 +410,35 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
378
410
  server.registerTool(
379
411
  'create',
380
412
  {
381
- description:
413
+ description: toolDescriptionWithSyntax(
382
414
  'Write a new .nowline file after validation. Overwrites if the path already exists.',
415
+ ),
383
416
  inputSchema: z.object({
384
417
  path: z.string().describe('Absolute or relative path to write the .nowline file.'),
385
418
  source: z.string().describe('The .nowline source text to write.'),
386
419
  }),
387
420
  outputSchema: CreateOutputSchema,
388
421
  // Overwrites silently → destructive; same source always produces same file → idempotent.
389
- annotations: { destructiveHint: true, idempotentHint: true },
422
+ annotations: mutatingTool('Create Roadmap', {
423
+ destructiveHint: true,
424
+ idempotentHint: true,
425
+ }),
390
426
  },
391
427
  async (args) => {
392
- 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) {
428
+ try {
429
+ const abs = resolveAndGuard(args.path, allowedRoot);
430
+ const blocked = await diagnosticsErrorBlock(args.source, abs);
431
+ if (!blocked.ok) return blocked.response;
432
+ await fs.mkdir(path.dirname(abs), { recursive: true });
433
+ await fs.writeFile(abs, args.source, 'utf-8');
434
+ const structured = { ok: true, path: abs };
397
435
  return {
398
- content: [
399
- {
400
- type: 'text',
401
- text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
402
- },
403
- ],
404
- isError: true,
436
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
437
+ structuredContent: structured,
405
438
  };
439
+ } catch (err) {
440
+ return handleToolError(err, args.path);
406
441
  }
407
- await fs.mkdir(path.dirname(abs), { recursive: true });
408
- await fs.writeFile(abs, args.source, 'utf-8');
409
- const structured = { ok: true, path: abs };
410
- return {
411
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
412
- structuredContent: structured,
413
- };
414
442
  },
415
443
  );
416
444
 
@@ -419,7 +447,9 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
419
447
  server.registerTool(
420
448
  'update',
421
449
  {
422
- description: 'Replace an existing .nowline file after validation.',
450
+ description: toolDescriptionWithSyntax(
451
+ 'Replace an existing .nowline file after validation.',
452
+ ),
423
453
  inputSchema: z.object({
424
454
  path: z
425
455
  .string()
@@ -427,30 +457,25 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
427
457
  source: z.string().describe('The new .nowline source text.'),
428
458
  }),
429
459
  outputSchema: UpdateOutputSchema,
430
- annotations: { idempotentHint: true },
460
+ annotations: mutatingTool('Update Roadmap', {
461
+ destructiveHint: true,
462
+ idempotentHint: true,
463
+ }),
431
464
  },
432
465
  async (args) => {
433
- 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) {
466
+ try {
467
+ const abs = resolveAndGuard(args.path, allowedRoot);
468
+ const blocked = await diagnosticsErrorBlock(args.source, abs);
469
+ if (!blocked.ok) return blocked.response;
470
+ await fs.writeFile(abs, args.source, 'utf-8');
471
+ const structured = { ok: true, path: abs };
438
472
  return {
439
- content: [
440
- {
441
- type: 'text',
442
- text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
443
- },
444
- ],
445
- isError: true,
473
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
474
+ structuredContent: structured,
446
475
  };
476
+ } catch (err) {
477
+ return handleToolError(err, args.path);
447
478
  }
448
- await fs.writeFile(abs, args.source, 'utf-8');
449
- const structured = { ok: true, path: abs };
450
- return {
451
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
452
- structuredContent: structured,
453
- };
454
479
  },
455
480
  );
456
481
 
@@ -466,16 +491,20 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
466
491
  .describe('Absolute or relative path of the .nowline file to delete.'),
467
492
  }),
468
493
  outputSchema: DeleteOutputSchema,
469
- annotations: { destructiveHint: true },
494
+ annotations: mutatingTool('Delete Roadmap', { destructiveHint: true }),
470
495
  },
471
496
  async (args) => {
472
- const abs = resolveAndGuard(args.path, allowedRoot);
473
- await fs.unlink(abs);
474
- const structured = { path: abs };
475
- return {
476
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
477
- structuredContent: structured,
478
- };
497
+ try {
498
+ const abs = resolveAndGuard(args.path, allowedRoot);
499
+ await fs.unlink(abs);
500
+ const structured = { path: abs };
501
+ return {
502
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
503
+ structuredContent: structured,
504
+ };
505
+ } catch (err) {
506
+ return handleToolError(err, args.path);
507
+ }
479
508
  },
480
509
  );
481
510
 
@@ -498,30 +527,46 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
498
527
  .describe('Whether to scan subdirectories. Defaults to false.'),
499
528
  }),
500
529
  outputSchema: ListOutputSchema,
501
- annotations: { readOnlyHint: true, idempotentHint: true },
530
+ annotations: readOnlyTool('List Roadmaps'),
502
531
  },
503
532
  async (args) => {
504
- const dir = args.directory ? resolveAndGuard(args.directory, allowedRoot) : allowedRoot;
505
- const recursive = args.recursive ?? false;
506
- const paths = await listNowlineFiles(dir, recursive);
507
- const structured = { paths };
508
- return {
509
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
510
- structuredContent: structured,
511
- };
533
+ try {
534
+ const dir = args.directory
535
+ ? resolveAndGuard(args.directory, allowedRoot)
536
+ : allowedRoot;
537
+ const recursive = args.recursive ?? false;
538
+ const paths = await listNowlineFiles(dir, recursive);
539
+ const structured = { paths };
540
+ return {
541
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
542
+ structuredContent: structured,
543
+ };
544
+ } catch (err) {
545
+ return handleToolError(err, args.directory);
546
+ }
512
547
  },
513
548
  );
514
549
 
515
550
  // ---- render -------------------------------------------------------------
516
551
 
517
- server.registerTool(
552
+ registerAppTool(
553
+ server,
518
554
  'render',
519
555
  {
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.',
556
+ description: toolDescriptionWithSyntax(
557
+ 'Validate then render a .nowline roadmap to SVG or PNG (combined validate+render+share). ' +
558
+ 'Returns structured diagnostics on error-severity input instead of a raw kernel error.',
559
+ ),
522
560
  inputSchema: z.object({
523
561
  source: z.string().optional().describe('Inline .nowline source text.'),
524
- path: z.string().optional().describe('Path to the .nowline file.'),
562
+ path: z
563
+ .string()
564
+ .optional()
565
+ .describe(
566
+ 'Real local filesystem path to the .nowline file (e.g. /Users/name/Desktop/foo.nowline). ' +
567
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
568
+ 'those do not exist on the host filesystem. Pass `source` instead.',
569
+ ),
525
570
  format: z
526
571
  .enum(['svg', 'png'])
527
572
  .optional()
@@ -542,25 +587,52 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
542
587
  output: z
543
588
  .string()
544
589
  .optional()
545
- .describe('Write output to this path instead of returning inline.'),
590
+ .describe(
591
+ 'Real local filesystem path to write the output file (e.g. /Users/name/Desktop/roadmap.svg). ' +
592
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
593
+ 'omit this parameter to receive output inline instead.',
594
+ ),
546
595
  share: z
547
596
  .boolean()
548
597
  .optional()
549
598
  .describe(
550
599
  'When true, include a shareUrl pointing to https://free.nowline.io/open.',
551
600
  ),
601
+ review: z
602
+ .boolean()
603
+ .optional()
604
+ .describe(
605
+ '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.',
606
+ ),
552
607
  preview: z
553
608
  .boolean()
554
609
  .optional()
555
610
  .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.',
611
+ 'When true, force the in-chat MCP Apps preview. On MCP Apps hosts the preview auto-renders via _meta.ui without this flag.',
557
612
  ),
558
613
  }),
559
614
  outputSchema: RenderOutputSchema,
560
- annotations: { readOnlyHint: true, idempotentHint: true },
615
+ annotations: readOnlyTool('Render Roadmap'),
616
+ _meta: {
617
+ ui: { resourceUri: PREVIEW_UI_URI },
618
+ 'openai/outputTemplate': PREVIEW_UI_URI,
619
+ },
561
620
  },
562
621
  async (args) => {
563
- const { source, filePath } = await sourceAndPath(args, allowedRoot);
622
+ // Only the filesystem touchpoints (reading the input, writing the
623
+ // output) map to structured NL.MCP.* errors; a kernel render fault
624
+ // bubbles unchanged so it is never mislabeled as a path/IO failure.
625
+ let source: string;
626
+ let filePath: string;
627
+ try {
628
+ ({ source, filePath } = await sourceAndPath(args, allowedRoot));
629
+ } catch (err) {
630
+ return handleToolError(err, args.path);
631
+ }
632
+
633
+ const blocked = await diagnosticsErrorBlock(source, filePath);
634
+ if (!blocked.ok) return blocked.response;
635
+
564
636
  const format: ExportFormat = args.format ?? 'svg';
565
637
  const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
566
638
  const inputs: RenderInputs = {
@@ -571,48 +643,99 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
571
643
  width: args.width,
572
644
  pngScale: args.scale,
573
645
  };
574
- if (format === 'png') {
646
+ const host = createNodeHostEnv(filePath);
647
+ const appActive = args.preview === true || clientSupportsAppsUi(server);
648
+ // Skip the full render when the bytes won't be used: apps host with
649
+ // no write-to-disk path and no review attachment requested.
650
+ const needsRender = !appActive || !!args.output || args.review === true;
651
+ if (needsRender && (format === 'png' || args.review === true)) {
575
652
  const result = await resolveFonts({ headless: true });
576
653
  inputs.fonts = { sans: result.sans, mono: result.mono };
577
654
  }
578
- const host = createNodeHostEnv(filePath);
579
- const bytes = await exportDocument(source, format, inputs, host);
655
+ const bytes = needsRender
656
+ ? await exportDocument(source, format, inputs, host)
657
+ : new Uint8Array(0);
580
658
  const shareUrl = args.share
581
659
  ? (buildShareLink({ source, share: true }) ?? undefined)
582
660
  : undefined;
583
661
 
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
- : [];
662
+ const insights = await collectMcpLayoutInsights({
663
+ source,
664
+ filePath,
665
+ today,
666
+ theme: args.theme ?? 'light',
667
+ width: args.width,
668
+ locale: 'en-US',
669
+ readFile: host.readSource,
670
+ doc: blocked.doc,
671
+ });
672
+
673
+ const previewPayload: PreviewPayload = {
674
+ source,
675
+ theme: args.theme,
676
+ now: args.now,
677
+ width: args.width,
678
+ locale: 'en-US',
679
+ };
680
+
681
+ const reviewBlocks =
682
+ args.review === true
683
+ ? await buildReviewContentBlocks(source, format, bytes, inputs, host)
684
+ : [];
685
+
686
+ const insightHintBlocks =
687
+ insights.length > 0 ? [{ type: 'text' as const, text: LAYOUT_INSIGHT_HINT }] : [];
688
+ const insightsField = insights.length > 0 ? { insights } : {};
599
689
 
600
690
  if (args.output) {
601
- const outAbs = resolveAndGuard(args.output, allowedRoot);
602
- await fs.mkdir(path.dirname(outAbs), { recursive: true });
603
- await fs.writeFile(outAbs, bytes);
604
- const structured = { format, path: outAbs, bytes: bytes.byteLength, shareUrl };
691
+ try {
692
+ const outAbs = resolveAndGuard(args.output, allowedRoot);
693
+ await fs.mkdir(path.dirname(outAbs), { recursive: true });
694
+ await fs.writeFile(outAbs, bytes);
695
+ const structured = {
696
+ format,
697
+ path: outAbs,
698
+ bytes: bytes.byteLength,
699
+ shareUrl,
700
+ ...insightsField,
701
+ };
702
+ return {
703
+ content: [
704
+ ...(appActive ? [leanPreviewBlock(previewPayload)] : []),
705
+ { type: 'text' as const, text: JSON.stringify(structured, null, 2) },
706
+ ...insightHintBlocks,
707
+ ...reviewBlocks,
708
+ ],
709
+ structuredContent: structured,
710
+ };
711
+ } catch (err) {
712
+ return handleToolError(err, args.output);
713
+ }
714
+ }
715
+
716
+ if (appActive) {
717
+ const structured = {
718
+ format,
719
+ shareUrl,
720
+ ...insightsField,
721
+ };
605
722
  return {
606
723
  content: [
607
- { type: 'text', text: JSON.stringify(structured, null, 2) },
608
- ...previewBlocks,
724
+ leanPreviewBlock(previewPayload),
725
+ ...insightHintBlocks,
726
+ ...reviewBlocks,
609
727
  ],
610
728
  structuredContent: structured,
611
729
  };
612
730
  }
613
731
 
614
732
  if (format === 'png') {
615
- const structured = { format, bytes: bytes.byteLength, shareUrl };
733
+ const structured = {
734
+ format,
735
+ bytes: bytes.byteLength,
736
+ shareUrl,
737
+ ...insightsField,
738
+ };
616
739
  return {
617
740
  content: [
618
741
  {
@@ -620,17 +743,19 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
620
743
  data: Buffer.from(bytes).toString('base64'),
621
744
  mimeType: 'image/png',
622
745
  },
623
- ...previewBlocks,
746
+ ...insightHintBlocks,
747
+ ...reviewBlocks,
624
748
  ],
625
749
  structuredContent: structured,
626
750
  };
627
751
  }
628
752
  const svgText = new TextDecoder('utf-8').decode(bytes);
629
- const structured = { format, shareUrl };
753
+ const structured = { format, shareUrl, ...insightsField };
630
754
  return {
631
755
  content: [
632
756
  { type: 'text', text: svgText, mimeType: 'image/svg+xml' },
633
- ...previewBlocks,
757
+ ...insightHintBlocks,
758
+ ...reviewBlocks,
634
759
  ],
635
760
  structuredContent: structured,
636
761
  };
@@ -649,14 +774,26 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
649
774
  'Export a .nowline roadmap to any of the eight canonical formats. Byte-identical to `nowline -f <format>` for the same source and inputs.',
650
775
  inputSchema: z.object({
651
776
  source: z.string().optional().describe('Inline .nowline source text.'),
652
- path: z.string().optional().describe('Path to the .nowline file.'),
777
+ path: z
778
+ .string()
779
+ .optional()
780
+ .describe(
781
+ 'Real local filesystem path to the .nowline file (e.g. /Users/name/Desktop/foo.nowline). ' +
782
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
783
+ 'those do not exist on the host filesystem. Pass `source` instead.',
784
+ ),
653
785
  format: z
654
786
  .enum(EXPORT_FORMATS)
655
787
  .describe('Export format: pdf, html, mermaid, xlsx, msproj, or png.'),
656
788
  output: z
657
789
  .string()
658
790
  .optional()
659
- .describe('Path to write the output file. Required for binary formats.'),
791
+ .describe(
792
+ 'Real local filesystem path to write the output (e.g. /Users/name/Desktop/roadmap.pdf). ' +
793
+ 'Required for binary formats (pdf, xlsx, msproj, png). ' +
794
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
795
+ 'those do not exist on the host filesystem.',
796
+ ),
660
797
  now: z.string().optional().describe('Now-line date as YYYY-MM-DD (UTC).'),
661
798
  theme: z.enum(['light', 'dark', 'grayscale']).optional(),
662
799
  scale: z.number().optional().describe('PNG scale factor.'),
@@ -678,10 +815,23 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
678
815
  ),
679
816
  }),
680
817
  outputSchema: ExportOutputSchema,
681
- annotations: { readOnlyHint: true, idempotentHint: true },
818
+ annotations: readOnlyTool('Export Roadmap'),
682
819
  },
683
820
  async (args) => {
684
- const { source, filePath } = await sourceAndPath(args, allowedRoot);
821
+ // Only the filesystem touchpoints (reading the input, writing the
822
+ // output) map to structured NL.MCP.* errors; a kernel export fault
823
+ // bubbles unchanged so it is never mislabeled as a path/IO failure.
824
+ let source: string;
825
+ let filePath: string;
826
+ try {
827
+ ({ source, filePath } = await sourceAndPath(args, allowedRoot));
828
+ } catch (err) {
829
+ return handleToolError(err, args.path);
830
+ }
831
+
832
+ const blocked = await diagnosticsErrorBlock(source, filePath);
833
+ if (!blocked.ok) return blocked.response;
834
+
685
835
  const format: ExportFormat = args.format as NonRenderFormat;
686
836
  const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
687
837
  const inputs: RenderInputs = {
@@ -709,14 +859,18 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
709
859
  const isBinary = BINARY_FORMATS.has(format);
710
860
 
711
861
  if (args.output) {
712
- const outAbs = resolveAndGuard(args.output, allowedRoot);
713
- await fs.mkdir(path.dirname(outAbs), { recursive: true });
714
- await fs.writeFile(outAbs, bytes);
715
- const structured = { format, path: outAbs, bytes: bytes.byteLength, shareUrl };
716
- return {
717
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
718
- structuredContent: structured,
719
- };
862
+ try {
863
+ const outAbs = resolveAndGuard(args.output, allowedRoot);
864
+ await fs.mkdir(path.dirname(outAbs), { recursive: true });
865
+ await fs.writeFile(outAbs, bytes);
866
+ const structured = { format, path: outAbs, bytes: bytes.byteLength, shareUrl };
867
+ return {
868
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
869
+ structuredContent: structured,
870
+ };
871
+ } catch (err) {
872
+ return handleToolError(err, args.output);
873
+ }
720
874
  }
721
875
 
722
876
  if (isBinary) {
@@ -755,7 +909,14 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
755
909
  '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
910
  inputSchema: z.object({
757
911
  source: z.string().optional().describe('Inline source text to convert.'),
758
- path: z.string().optional().describe('Path to the source file.'),
912
+ path: z
913
+ .string()
914
+ .optional()
915
+ .describe(
916
+ 'Real local filesystem path to the source file. ' +
917
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
918
+ 'pass `source` instead.',
919
+ ),
759
920
  to: z
760
921
  .enum(['json', 'nowline'])
761
922
  .describe(
@@ -763,47 +924,51 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
763
924
  ),
764
925
  }),
765
926
  outputSchema: ConvertOutputSchema,
766
- annotations: { readOnlyHint: true, idempotentHint: true },
927
+ annotations: readOnlyTool('Convert Roadmap'),
767
928
  },
768
929
  async (args) => {
769
- if (args.to === 'json') {
770
- const { source, filePath } = await sourceAndPath(args, allowedRoot);
771
- const host = createNodeHostEnv(filePath);
772
- const jsonBytes = await exportDocument(
773
- source,
774
- 'json',
775
- {
776
- sourcePath: filePath,
777
- today: todayUtc(),
778
- locale: 'en-US',
779
- theme: 'light',
780
- },
781
- host,
782
- );
783
- const result = new TextDecoder('utf-8').decode(jsonBytes);
784
- const structured = { to: 'json' as const, result };
930
+ try {
931
+ if (args.to === 'json') {
932
+ const { source, filePath } = await sourceAndPath(args, allowedRoot);
933
+ const host = createNodeHostEnv(filePath);
934
+ const jsonBytes = await exportDocument(
935
+ source,
936
+ 'json',
937
+ {
938
+ sourcePath: filePath,
939
+ today: todayUtc(),
940
+ locale: 'en-US',
941
+ theme: 'light',
942
+ },
943
+ host,
944
+ );
945
+ const result = new TextDecoder('utf-8').decode(jsonBytes);
946
+ const structured = { to: 'json' as const, result };
947
+ return {
948
+ content: [{ type: 'text', text: result }],
949
+ structuredContent: structured,
950
+ };
951
+ }
952
+
953
+ // to: 'nowline' — input is a JSON AST string
954
+ const jsonSource =
955
+ args.source ??
956
+ (args.path
957
+ ? await fs.readFile(resolveAndGuard(args.path, allowedRoot), 'utf-8')
958
+ : null);
959
+ if (!jsonSource) {
960
+ throw new InputRequiredError('At least one of `source` or `path` is required.');
961
+ }
962
+ const { ast } = parseNowlineJson(jsonSource, args.path ?? 'input.json');
963
+ const result = printNowlineFile(ast);
964
+ const structured = { to: 'nowline' as const, result };
785
965
  return {
786
966
  content: [{ type: 'text', text: result }],
787
967
  structuredContent: structured,
788
968
  };
969
+ } catch (err) {
970
+ return handleToolError(err, args.path);
789
971
  }
790
-
791
- // to: 'nowline' — input is a JSON AST string
792
- const jsonSource =
793
- args.source ??
794
- (args.path
795
- ? await fs.readFile(resolveAndGuard(args.path, allowedRoot), 'utf-8')
796
- : null);
797
- if (!jsonSource) {
798
- throw new Error('At least one of `source` or `path` is required.');
799
- }
800
- const { ast } = parseNowlineJson(jsonSource, args.path ?? 'input.json');
801
- const result = printNowlineFile(ast);
802
- const structured = { to: 'nowline' as const, result };
803
- return {
804
- content: [{ type: 'text', text: result }],
805
- structuredContent: structured,
806
- };
807
972
  },
808
973
  );
809
974
 
@@ -816,7 +981,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
816
981
  'Return all supported themes, icons, locales, export formats, and template names in a single response.',
817
982
  inputSchema: z.object({}),
818
983
  outputSchema: CapabilitiesOutputSchema,
819
- annotations: { readOnlyHint: true, idempotentHint: true },
984
+ annotations: readOnlyTool('View Roadmap Capabilities'),
820
985
  },
821
986
  async () => {
822
987
  const structured = {
@@ -841,7 +1006,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
841
1006
  description: 'List supported color themes: light, dark, grayscale.',
842
1007
  inputSchema: z.object({}),
843
1008
  outputSchema: ListItemsOutputSchema,
844
- annotations: { readOnlyHint: true, idempotentHint: true },
1009
+ annotations: readOnlyTool('List Themes'),
845
1010
  },
846
1011
  async () => {
847
1012
  const structured = { items: [...CAPABILITIES.themes] };
@@ -861,7 +1026,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
861
1026
  'List built-in capacity-icon names usable in the `capacity-icon:` style property.',
862
1027
  inputSchema: z.object({}),
863
1028
  outputSchema: ListItemsOutputSchema,
864
- annotations: { readOnlyHint: true, idempotentHint: true },
1029
+ annotations: readOnlyTool('List Icons'),
865
1030
  },
866
1031
  async () => {
867
1032
  const structured = { items: [...CAPABILITIES.icons] };
@@ -880,7 +1045,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
880
1045
  description: 'List supported BCP-47 locale tags.',
881
1046
  inputSchema: z.object({}),
882
1047
  outputSchema: ListItemsOutputSchema,
883
- annotations: { readOnlyHint: true, idempotentHint: true },
1048
+ annotations: readOnlyTool('List Locales'),
884
1049
  },
885
1050
  async () => {
886
1051
  const structured = { items: [...CAPABILITIES.locales] };
@@ -900,7 +1065,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
900
1065
  'List all supported export formats (svg, png, pdf, html, mermaid, xlsx, msproj, json).',
901
1066
  inputSchema: z.object({}),
902
1067
  outputSchema: ListItemsOutputSchema,
903
- annotations: { readOnlyHint: true, idempotentHint: true },
1068
+ annotations: readOnlyTool('List Export Formats'),
904
1069
  },
905
1070
  async () => {
906
1071
  const structured = { items: [...CAPABILITIES.formats] };
@@ -919,7 +1084,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
919
1084
  description: 'List built-in template names usable with `nowline --init --template`.',
920
1085
  inputSchema: z.object({}),
921
1086
  outputSchema: ListItemsOutputSchema,
922
- annotations: { readOnlyHint: true, idempotentHint: true },
1087
+ annotations: readOnlyTool('List Templates'),
923
1088
  },
924
1089
  async () => {
925
1090
  const structured = { items: [...CAPABILITIES.templates] };
@@ -930,11 +1095,165 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
930
1095
  },
931
1096
  );
932
1097
 
1098
+ // ---- reference / examples / schema (discovery tools) --------------------
1099
+
1100
+ server.registerTool(
1101
+ 'reference',
1102
+ {
1103
+ description:
1104
+ 'Return the Nowline DSL reference (condensed cheatsheet or full man page). Callable alternative to the nowline://reference resource.',
1105
+ inputSchema: z.object({
1106
+ format: z
1107
+ .enum(['condensed', 'full'])
1108
+ .optional()
1109
+ .describe('Reference format. Defaults to condensed.'),
1110
+ }),
1111
+ outputSchema: ReferenceOutputSchema,
1112
+ annotations: readOnlyTool('View Roadmap Reference'),
1113
+ },
1114
+ async (args) => {
1115
+ const format = args.format ?? 'condensed';
1116
+ const text = format === 'full' ? REFERENCE_MAN_PAGE : REFERENCE_CHEATSHEET;
1117
+ const structured = { format, text };
1118
+ return {
1119
+ content: [{ type: 'text', text }],
1120
+ structuredContent: structured,
1121
+ };
1122
+ },
1123
+ );
1124
+
1125
+ server.registerTool(
1126
+ 'examples',
1127
+ {
1128
+ description:
1129
+ 'Return canonical .nowline example sources. Callable alternative to the nowline://examples resource.',
1130
+ inputSchema: z.object({
1131
+ name: z
1132
+ .string()
1133
+ .optional()
1134
+ .describe('Example name. Omit for the catalog plus minimal inline.'),
1135
+ }),
1136
+ outputSchema: ExamplesOutputSchema,
1137
+ annotations: readOnlyTool('View Roadmap Examples'),
1138
+ },
1139
+ async (args) => {
1140
+ const exampleNames = EXAMPLES.map((e) => exampleShortName(e.name));
1141
+ if (args.name) {
1142
+ const ex = findExample(args.name);
1143
+ if (!ex) {
1144
+ return {
1145
+ content: [
1146
+ {
1147
+ type: 'text',
1148
+ text: JSON.stringify({
1149
+ error: `Unknown example "${args.name}".`,
1150
+ names: exampleNames,
1151
+ }),
1152
+ },
1153
+ ],
1154
+ isError: true,
1155
+ };
1156
+ }
1157
+ const structured = { name: exampleShortName(ex.name), source: ex.content };
1158
+ return {
1159
+ content: [{ type: 'text', text: ex.content }],
1160
+ structuredContent: structured,
1161
+ };
1162
+ }
1163
+ const minimal = findExample('minimal') ?? EXAMPLES[0];
1164
+ const structured = {
1165
+ names: exampleNames,
1166
+ name: exampleShortName(minimal.name),
1167
+ source: minimal.content,
1168
+ };
1169
+ return {
1170
+ content: [
1171
+ {
1172
+ type: 'text',
1173
+ text: `# Examples\n\n${exampleNames.map((n) => `- ${n}`).join('\n')}\n\n## ${structured.name}\n\n${minimal.content}`,
1174
+ },
1175
+ ],
1176
+ structuredContent: structured,
1177
+ };
1178
+ },
1179
+ );
1180
+
1181
+ server.registerTool(
1182
+ 'schema',
1183
+ {
1184
+ description:
1185
+ 'Return the structured Nowline DSL key vocabulary (directive keys, entity types, item properties).',
1186
+ inputSchema: z.object({}),
1187
+ outputSchema: SchemaOutputSchema,
1188
+ annotations: readOnlyTool('View Roadmap Schema'),
1189
+ },
1190
+ async () => {
1191
+ const structured = {
1192
+ directiveKeys: [...SCHEMA_VOCABULARY.directiveKeys],
1193
+ entityTypes: [...SCHEMA_VOCABULARY.entityTypes],
1194
+ itemPropertyKeys: [...SCHEMA_VOCABULARY.itemPropertyKeys],
1195
+ };
1196
+ return {
1197
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1198
+ structuredContent: structured,
1199
+ };
1200
+ },
1201
+ );
1202
+
933
1203
  return server;
934
1204
  }
935
1205
 
936
1206
  // ---- Helpers ----------------------------------------------------------------
937
1207
 
1208
+ type ContentBlock =
1209
+ | { type: 'text'; text: string; mimeType?: string }
1210
+ | { type: 'image'; data: string; mimeType: string };
1211
+
1212
+ async function buildReviewContentBlocks(
1213
+ source: string,
1214
+ format: ExportFormat,
1215
+ artifactBytes: Uint8Array,
1216
+ inputs: RenderInputs,
1217
+ host: HostEnv,
1218
+ ): Promise<ContentBlock[]> {
1219
+ const blocks: ContentBlock[] = [
1220
+ {
1221
+ type: 'text',
1222
+ text:
1223
+ 'Review this raster for layout issues (truncated labels, crowded lanes, ' +
1224
+ 'now-line position) before finalizing.',
1225
+ },
1226
+ ];
1227
+
1228
+ const artifactWidth = inputs.width ?? DEFAULT_RENDER_WIDTH;
1229
+ let inspectionBytes: Uint8Array;
1230
+
1231
+ if (format === 'png' && artifactWidth <= REVIEW_MAX_WIDTH) {
1232
+ inspectionBytes = artifactBytes;
1233
+ } else {
1234
+ const reviewInputs: RenderInputs = {
1235
+ ...inputs,
1236
+ width: Math.min(artifactWidth, REVIEW_MAX_WIDTH),
1237
+ pngScale: 1,
1238
+ };
1239
+ if (!reviewInputs.fonts) {
1240
+ const fonts = await resolveFonts({ headless: true });
1241
+ reviewInputs.fonts = { sans: fonts.sans, mono: fonts.mono };
1242
+ }
1243
+ inspectionBytes = await exportDocument(source, 'png', reviewInputs, host);
1244
+ }
1245
+
1246
+ if (format !== 'png' || inspectionBytes.byteLength !== artifactBytes.byteLength) {
1247
+ blocks.push({
1248
+ type: 'image',
1249
+ data: Buffer.from(inspectionBytes).toString('base64'),
1250
+ mimeType: 'image/png',
1251
+ });
1252
+ }
1253
+
1254
+ return blocks;
1255
+ }
1256
+
938
1257
  async function listNowlineFiles(dir: string, recursive: boolean): Promise<string[]> {
939
1258
  const results: string[] = [];
940
1259
  try {