@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/dist/server.js CHANGED
@@ -7,128 +7,81 @@
7
7
  // Spec: specs/mcp.md.
8
8
  import { promises as fs } from 'node:fs';
9
9
  import * as path from 'node:path';
10
+ import { getUiCapability, RESOURCE_MIME_TYPE, registerAppResource, registerAppTool, } from '@modelcontextprotocol/ext-apps/server';
10
11
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
- import { collectDocumentDiagnostics, createNowlineServices, parseNowlineJson, printNowlineFile, } from '@nowline/core';
12
+ import { parseNowlineJson, printNowlineFile } from '@nowline/core';
12
13
  import { exportDocument, } from '@nowline/export';
13
14
  import { resolveFonts } from '@nowline/export-core';
14
15
  import { buildShareLink } from '@nowline/share-link';
15
- import { URI } from 'langium';
16
16
  import { z } from 'zod';
17
+ import { NOWLINE_MCP_ICONS } from './branding.js';
17
18
  import { CAPABILITIES } from './capabilities.js';
18
- import { CONVERSIONS_GUIDE, EXAMPLES, REFERENCE_MAN_PAGE } from './generated/resources.js';
19
- import { UI_BUNDLE } from './generated/ui-bundle.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 { CONVERSIONS_GUIDE, EXAMPLES, REFERENCE_MAN_PAGE, } from './generated/resources.js';
21
+ import { PREVIEW_HTML } from './generated/ui-bundle.js';
20
22
  import { registerPrompts } from './prompts.js';
21
- import { CapabilitiesOutputSchema, ConvertOutputSchema, CreateOutputSchema, DeleteOutputSchema, ExportOutputSchema, ListItemsOutputSchema, ListOutputSchema, ReadOutputSchema, RenderOutputSchema, UpdateOutputSchema, ValidateOutputSchema, } from './schemas.js';
23
+ import { REFERENCE_CHEATSHEET } from './reference-cheatsheet.js';
24
+ import { SCHEMA_VOCABULARY } from './schema-vocab.js';
25
+ import { CapabilitiesOutputSchema, ConvertOutputSchema, CreateOutputSchema, DeleteOutputSchema, ExamplesOutputSchema, ExportOutputSchema, ListItemsOutputSchema, ListOutputSchema, ReadOutputSchema, ReferenceOutputSchema, RenderOutputSchema, SchemaOutputSchema, UpdateOutputSchema, ValidateOutputSchema, } from './schemas.js';
22
26
  // ---- MCP Apps UI (in-chat live preview) -------------------------------------
23
27
  //
24
- // The MCP Apps extension (SEP-1865) lets a tool return an interactive HTML
25
- // resource the host renders in a sandboxed iframe. We use the embedded-resource
26
- // form: `render` returns a self-contained text/html resource that inlines the
27
- // browser preview bundle (UI_BUNDLE, the @nowline/browser + @nowline/preview-
28
- // shell pipeline) plus the injected source. It is emitted only when the client
29
- // advertises the UI extension capability or the caller passes `preview: true`,
30
- // so plain stdio operation is unchanged and non-UI hosts still receive the
31
- // SVG/PNG content block alongside it (graceful degradation).
28
+ // Official MCP Apps model (SEP-1865): a pre-declared ui:// resource serves
29
+ // static HTML; the render tool declares _meta.ui.resourceUri; per-call data
30
+ // flows through the ontoolresult handshake. When an MCP Apps host is active,
31
+ // render returns a lean nowline.preview JSON payload (no inline SVG/PNG) so
32
+ // results stay under the host's ~150K inline cap. Non-apps hosts still get
33
+ // the full SVG/PNG inline (graceful degradation).
32
34
  /** SEP-1865 UI extension capability id; also probed under common short keys. */
33
35
  const MCP_APPS_UI_CAPABILITY = 'io.modelcontextprotocol/ui';
34
- const PREVIEW_UI_URI = 'ui://nowline/preview';
35
- /** SEP-1865 mandates the text/html;profile=mcp-app media type for UI resources. */
36
- const PREVIEW_UI_MIME = 'text/html;profile=mcp-app';
36
+ /** Versioned URI doubles as a cache key — bump suffix on bundle changes. */
37
+ export const PREVIEW_UI_URI = 'ui://nowline/preview-v1';
37
38
  function clientSupportsAppsUi(server) {
38
- // Extension capabilities are negotiated under `experimental` in SDK 1.29
39
- // (it does not yet model SEP-1724 extensions as a first-class field), so
40
- // probe the canonical id plus the short `ui` / `apps` aliases some hosts use.
41
- const experimental = server.server.getClientCapabilities()?.experimental;
42
- if (!experimental)
39
+ // SEP-1724 negotiated extensions: canonical id under `extensions`.
40
+ const caps = server.server.getClientCapabilities();
41
+ if (!caps)
43
42
  return false;
44
- return Boolean(experimental[MCP_APPS_UI_CAPABILITY] || experimental.ui || experimental.apps);
43
+ if (getUiCapability(caps))
44
+ return true;
45
+ // Hosts also advertise under `experimental` or short `ui` / `apps` aliases.
46
+ const buckets = [caps.extensions, caps.experimental].filter((bucket) => Boolean(bucket));
47
+ return buckets.some((bucket) => Boolean(bucket[MCP_APPS_UI_CAPABILITY] || bucket.ui || bucket.apps));
45
48
  }
46
- function buildPreviewHtml(payload) {
47
- // The payload (including the .nowline source) is injected as a JSON
48
- // <script> block rather than interpolated into executable JS, so source
49
- // text with quotes/backticks can't break out. Escaping `<` as \u003c keeps
50
- // any embedded "</script>" from closing the block early; JSON.parse in the
51
- // bundle decodes it back. The bundle injects its own stylesheet at runtime,
52
- // so only the root-element sizing CSS is inlined here.
53
- const data = JSON.stringify(payload).replace(/</g, '\\u003c');
54
- return [
55
- '<!doctype html>',
56
- '<html lang="en">',
57
- '<head>',
58
- '<meta charset="utf-8" />',
59
- '<meta name="viewport" content="width=device-width, initial-scale=1" />',
60
- '<title>Nowline preview</title>',
61
- '<style>html,body,#nl-preview-root{margin:0;padding:0;height:100%;width:100%;overflow:hidden;}</style>',
62
- '</head>',
63
- '<body>',
64
- '<div id="nl-preview-root"></div>',
65
- `<script id="nl-preview-data" type="application/json">${data}</script>`,
66
- `<script>${UI_BUNDLE}</script>`,
67
- '</body>',
68
- '</html>',
69
- '',
70
- ].join('\n');
71
- }
72
- function previewResourceBlock(payload) {
49
+ function leanPreviewBlock(payload) {
73
50
  return {
74
- type: 'resource',
75
- resource: {
76
- uri: PREVIEW_UI_URI,
77
- mimeType: PREVIEW_UI_MIME,
78
- text: buildPreviewHtml(payload),
79
- },
51
+ type: 'text',
52
+ text: JSON.stringify({
53
+ kind: 'nowline.preview',
54
+ source: payload.source,
55
+ theme: payload.theme,
56
+ now: payload.now,
57
+ width: payload.width,
58
+ locale: payload.locale,
59
+ }),
80
60
  };
81
61
  }
82
- function collectMcpDiagnostics(doc, filePath) {
83
- const raw = collectDocumentDiagnostics(doc);
84
- const out = [];
85
- for (const d of raw) {
86
- if (d.origin === 'lexer' || d.origin === 'parser') {
87
- out.push({
88
- file: filePath,
89
- line: 1,
90
- column: 1,
91
- severity: 'error',
92
- code: d.origin === 'lexer' ? 'lexing-error' : 'parsing-error',
93
- message: d.error.message,
94
- });
95
- }
96
- else {
97
- const diag = d.diagnostic;
98
- const range = diag.range;
99
- out.push({
100
- file: filePath,
101
- line: (range?.start.line ?? 0) + 1,
102
- column: (range?.start.character ?? 0) + 1,
103
- severity: diag.severity === 1 ? 'error' : 'warning',
104
- code: String(diag.code ?? 'unknown'),
105
- message: diag.message,
106
- });
107
- }
108
- }
109
- return out;
110
- }
111
- // ---- Langium services -------------------------------------------------------
112
- let cachedServices;
113
- let docCounter = 0;
114
- function getServices() {
115
- if (!cachedServices)
116
- cachedServices = createNowlineServices();
117
- return cachedServices;
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
+ };
118
70
  }
119
- async function buildDocument(source) {
120
- const services = getServices();
121
- const uri = URI.parse(`memory:///mcp-${++docCounter}.nowline`);
122
- const doc = services.shared.workspace.LangiumDocumentFactory.fromString(source, uri);
123
- await services.shared.workspace.DocumentBuilder.build([doc], { validation: true });
124
- return doc;
71
+ function mutatingTool(title, opts) {
72
+ return {
73
+ title,
74
+ destructiveHint: opts.destructiveHint,
75
+ ...(opts.idempotentHint ? { idempotentHint: true } : {}),
76
+ openWorldHint: false,
77
+ };
125
78
  }
126
- // ---- Allowed-root enforcement -----------------------------------------------
79
+ // ---- Server factory ---------------------------------------------------------
127
80
  function resolveAndGuard(filePath, allowedRoot) {
128
81
  const abs = path.resolve(allowedRoot, filePath);
129
82
  const guard = path.resolve(allowedRoot);
130
83
  if (!abs.startsWith(guard + path.sep) && abs !== guard) {
131
- throw new Error(`Path ${filePath} is outside the allowed root ${allowedRoot}`);
84
+ throw new PathOutsideRootError(filePath, allowedRoot);
132
85
  }
133
86
  return abs;
134
87
  }
@@ -163,6 +116,13 @@ function todayUtc() {
163
116
  const now = new Date();
164
117
  return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
165
118
  }
119
+ function exampleShortName(fullName) {
120
+ return fullName.endsWith('.nowline') ? fullName.slice(0, -'.nowline'.length) : fullName;
121
+ }
122
+ function findExample(name) {
123
+ const withExt = name.endsWith('.nowline') ? name : `${name}.nowline`;
124
+ return EXAMPLES.find((e) => e.name === withExt || e.name === name);
125
+ }
166
126
  async function sourceAndPath(args, allowedRoot) {
167
127
  if (args.source !== undefined && args.path === undefined) {
168
128
  return { source: args.source, filePath: path.join(allowedRoot, 'unnamed.nowline') };
@@ -172,16 +132,25 @@ async function sourceAndPath(args, allowedRoot) {
172
132
  const source = args.source ?? (await fs.readFile(abs, 'utf-8'));
173
133
  return { source, filePath: abs };
174
134
  }
175
- 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.');
176
136
  }
177
137
  export function createMcpServer(opts = {}) {
178
138
  const allowedRoot = opts.allowedRoot ?? process.cwd();
179
139
  const server = new McpServer({
180
140
  name: opts.name ?? 'nowline',
181
141
  version: opts.version ?? '0.6.0',
142
+ icons: [...NOWLINE_MCP_ICONS],
143
+ }, {
144
+ instructions: 'Nowline manages roadmaps written in the .nowline plain-text DSL — NOT JSON or any other ' +
145
+ 'structured format. All `source` parameters expect `.nowline` DSL text (starts with `nowline v1`). ' +
146
+ 'Workflow: 1. call `reference` or `examples` to learn syntax → 2. write `.nowline` → ' +
147
+ '3. call `render` (validates + renders; or `validate` alone) → 4. fix errors keyed on `NL.E####` ' +
148
+ 'and re-render → 5. review returned layout `insights` (what reflowed) → 6. when uncertain, ' +
149
+ 'call `render` with `review:true` for a final visual check. JSON in `convert` is AST conversion only.',
182
150
  });
183
151
  // ---- Resources ----------------------------------------------------------
184
152
  server.registerResource('nowline-reference', 'nowline://reference', {
153
+ title: 'Roadmap Reference',
185
154
  description: 'Full DSL reference (nowline.5 man page): syntax, directives, and examples.',
186
155
  mimeType: 'text/plain',
187
156
  }, async () => ({
@@ -190,6 +159,7 @@ export function createMcpServer(opts = {}) {
190
159
  ],
191
160
  }));
192
161
  server.registerResource('nowline-examples', 'nowline://examples', {
162
+ title: 'Roadmap Examples',
193
163
  description: 'Canonical example .nowline files from the official examples/ directory.',
194
164
  mimeType: 'text/plain',
195
165
  }, async () => ({
@@ -200,6 +170,7 @@ export function createMcpServer(opts = {}) {
200
170
  })),
201
171
  }));
202
172
  server.registerResource('nowline-conversions', 'nowline://conversions', {
173
+ title: 'Conversion Guide',
203
174
  description: 'LLM-mediated conversion guide: how to translate Mermaid gantt, MS Project, Excel, Google Sheets timeline, and generic CSV into Nowline DSL.',
204
175
  mimeType: 'text/plain',
205
176
  }, async () => ({
@@ -211,11 +182,23 @@ export function createMcpServer(opts = {}) {
211
182
  },
212
183
  ],
213
184
  }));
185
+ registerAppResource(server, 'nowline-preview', PREVIEW_UI_URI, {
186
+ title: 'Roadmap Preview',
187
+ description: 'Interactive in-chat roadmap preview (MCP Apps). Hydrates via ontoolresult.',
188
+ }, async () => ({
189
+ contents: [
190
+ {
191
+ uri: PREVIEW_UI_URI,
192
+ mimeType: RESOURCE_MIME_TYPE,
193
+ text: PREVIEW_HTML,
194
+ },
195
+ ],
196
+ }));
214
197
  // ---- Prompts ------------------------------------------------------------
215
198
  registerPrompts(server);
216
199
  // ---- validate -----------------------------------------------------------
217
200
  server.registerTool('validate', {
218
- description: 'Parse and validate a .nowline roadmap. Returns ok=true and an empty diagnostics array if valid, or ok=false with structured diagnostics.',
201
+ description: toolDescriptionWithSyntax('Parse and validate a .nowline roadmap. Returns ok=true with optional layout insights when valid, or ok=false with structured diagnostics.'),
219
202
  inputSchema: z.object({
220
203
  source: z.string().optional().describe('Inline .nowline source text to validate.'),
221
204
  path: z
@@ -224,17 +207,41 @@ export function createMcpServer(opts = {}) {
224
207
  .describe('Absolute or relative path to a .nowline file to validate.'),
225
208
  }),
226
209
  outputSchema: ValidateOutputSchema,
227
- annotations: { readOnlyHint: true, idempotentHint: true },
210
+ annotations: readOnlyTool('Validate Roadmap'),
228
211
  }, async (args) => {
229
- const { source, filePath } = await sourceAndPath(args, allowedRoot);
230
- const doc = await buildDocument(source);
231
- const diagnostics = collectMcpDiagnostics(doc, filePath);
232
- const ok = diagnostics.every((d) => d.severity !== 'error');
233
- const structured = { ok, diagnostics };
234
- return {
235
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
236
- structuredContent: structured,
237
- };
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
+ }
238
245
  });
239
246
  // ---- read ---------------------------------------------------------------
240
247
  server.registerTool('read', {
@@ -245,53 +252,55 @@ export function createMcpServer(opts = {}) {
245
252
  .describe('Absolute or relative path to the .nowline file to read.'),
246
253
  }),
247
254
  outputSchema: ReadOutputSchema,
248
- annotations: { readOnlyHint: true, idempotentHint: true },
255
+ annotations: readOnlyTool('Read Roadmap'),
249
256
  }, async (args) => {
250
- const abs = resolveAndGuard(args.path, allowedRoot);
251
- const source = await fs.readFile(abs, 'utf-8');
252
- const structured = { path: abs, source };
253
- return {
254
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
255
- structuredContent: structured,
256
- };
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
+ }
257
269
  });
258
270
  // ---- create -------------------------------------------------------------
259
271
  server.registerTool('create', {
260
- description: 'Write a new .nowline file after validation. Overwrites if the path already exists.',
272
+ description: toolDescriptionWithSyntax('Write a new .nowline file after validation. Overwrites if the path already exists.'),
261
273
  inputSchema: z.object({
262
274
  path: z.string().describe('Absolute or relative path to write the .nowline file.'),
263
275
  source: z.string().describe('The .nowline source text to write.'),
264
276
  }),
265
277
  outputSchema: CreateOutputSchema,
266
278
  // Overwrites silently → destructive; same source always produces same file → idempotent.
267
- annotations: { destructiveHint: true, idempotentHint: true },
279
+ annotations: mutatingTool('Create Roadmap', {
280
+ destructiveHint: true,
281
+ idempotentHint: true,
282
+ }),
268
283
  }, async (args) => {
269
- const abs = resolveAndGuard(args.path, allowedRoot);
270
- const doc = await buildDocument(args.source);
271
- const diagnostics = collectMcpDiagnostics(doc, abs);
272
- const errors = diagnostics.filter((d) => d.severity === 'error');
273
- if (errors.length > 0) {
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 };
274
292
  return {
275
- content: [
276
- {
277
- type: 'text',
278
- text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
279
- },
280
- ],
281
- isError: true,
293
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
294
+ structuredContent: structured,
282
295
  };
283
296
  }
284
- await fs.mkdir(path.dirname(abs), { recursive: true });
285
- await fs.writeFile(abs, args.source, 'utf-8');
286
- const structured = { ok: true, path: abs };
287
- return {
288
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
289
- structuredContent: structured,
290
- };
297
+ catch (err) {
298
+ return handleToolError(err, args.path);
299
+ }
291
300
  });
292
301
  // ---- update -------------------------------------------------------------
293
302
  server.registerTool('update', {
294
- description: 'Replace an existing .nowline file after validation.',
303
+ description: toolDescriptionWithSyntax('Replace an existing .nowline file after validation.'),
295
304
  inputSchema: z.object({
296
305
  path: z
297
306
  .string()
@@ -299,29 +308,26 @@ export function createMcpServer(opts = {}) {
299
308
  source: z.string().describe('The new .nowline source text.'),
300
309
  }),
301
310
  outputSchema: UpdateOutputSchema,
302
- annotations: { idempotentHint: true },
311
+ annotations: mutatingTool('Update Roadmap', {
312
+ destructiveHint: true,
313
+ idempotentHint: true,
314
+ }),
303
315
  }, async (args) => {
304
- const abs = resolveAndGuard(args.path, allowedRoot);
305
- const doc = await buildDocument(args.source);
306
- const diagnostics = collectMcpDiagnostics(doc, abs);
307
- const errors = diagnostics.filter((d) => d.severity === 'error');
308
- if (errors.length > 0) {
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 };
309
323
  return {
310
- content: [
311
- {
312
- type: 'text',
313
- text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
314
- },
315
- ],
316
- isError: true,
324
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
325
+ structuredContent: structured,
317
326
  };
318
327
  }
319
- await fs.writeFile(abs, args.source, 'utf-8');
320
- const structured = { ok: true, path: abs };
321
- return {
322
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
323
- structuredContent: structured,
324
- };
328
+ catch (err) {
329
+ return handleToolError(err, args.path);
330
+ }
325
331
  });
326
332
  // ---- delete -------------------------------------------------------------
327
333
  server.registerTool('delete', {
@@ -332,15 +338,20 @@ export function createMcpServer(opts = {}) {
332
338
  .describe('Absolute or relative path of the .nowline file to delete.'),
333
339
  }),
334
340
  outputSchema: DeleteOutputSchema,
335
- annotations: { destructiveHint: true },
341
+ annotations: mutatingTool('Delete Roadmap', { destructiveHint: true }),
336
342
  }, async (args) => {
337
- const abs = resolveAndGuard(args.path, allowedRoot);
338
- await fs.unlink(abs);
339
- const structured = { path: abs };
340
- return {
341
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
342
- structuredContent: structured,
343
- };
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
+ }
344
355
  });
345
356
  // ---- list ---------------------------------------------------------------
346
357
  server.registerTool('list', {
@@ -356,23 +367,36 @@ export function createMcpServer(opts = {}) {
356
367
  .describe('Whether to scan subdirectories. Defaults to false.'),
357
368
  }),
358
369
  outputSchema: ListOutputSchema,
359
- annotations: { readOnlyHint: true, idempotentHint: true },
370
+ annotations: readOnlyTool('List Roadmaps'),
360
371
  }, async (args) => {
361
- const dir = args.directory ? resolveAndGuard(args.directory, allowedRoot) : allowedRoot;
362
- const recursive = args.recursive ?? false;
363
- const paths = await listNowlineFiles(dir, recursive);
364
- const structured = { paths };
365
- return {
366
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
367
- structuredContent: structured,
368
- };
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
+ }
369
387
  });
370
388
  // ---- render -------------------------------------------------------------
371
- server.registerTool('render', {
372
- description: '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.',
389
+ registerAppTool(server, 'render', {
390
+ description: toolDescriptionWithSyntax('Validate then render a .nowline roadmap to SVG or PNG (combined validate+render+share). ' +
391
+ 'Returns structured diagnostics on error-severity input instead of a raw kernel error.'),
373
392
  inputSchema: z.object({
374
393
  source: z.string().optional().describe('Inline .nowline source text.'),
375
- path: z.string().optional().describe('Path to the .nowline file.'),
394
+ path: z
395
+ .string()
396
+ .optional()
397
+ .describe('Real local filesystem path to the .nowline file (e.g. /Users/name/Desktop/foo.nowline). ' +
398
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
399
+ 'those do not exist on the host filesystem. Pass `source` instead.'),
376
400
  format: z
377
401
  .enum(['svg', 'png'])
378
402
  .optional()
@@ -393,20 +417,43 @@ export function createMcpServer(opts = {}) {
393
417
  output: z
394
418
  .string()
395
419
  .optional()
396
- .describe('Write output to this path instead of returning inline.'),
420
+ .describe('Real local filesystem path to write the output file (e.g. /Users/name/Desktop/roadmap.svg). ' +
421
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
422
+ 'omit this parameter to receive output inline instead.'),
397
423
  share: z
398
424
  .boolean()
399
425
  .optional()
400
426
  .describe('When true, include a shareUrl pointing to https://free.nowline.io/open.'),
427
+ review: z
428
+ .boolean()
429
+ .optional()
430
+ .describe('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.'),
401
431
  preview: z
402
432
  .boolean()
403
433
  .optional()
404
- .describe('When true, also return an interactive in-chat HTML preview (MCP Apps UI). Auto-enabled when the client advertises MCP Apps UI support.'),
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.'),
405
435
  }),
406
436
  outputSchema: RenderOutputSchema,
407
- annotations: { readOnlyHint: true, idempotentHint: true },
437
+ annotations: readOnlyTool('Render Roadmap'),
438
+ _meta: {
439
+ ui: { resourceUri: PREVIEW_UI_URI },
440
+ 'openai/outputTemplate': PREVIEW_UI_URI,
441
+ },
408
442
  }, async (args) => {
409
- 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
+ }
454
+ const blocked = await diagnosticsErrorBlock(source, filePath);
455
+ if (!blocked.ok)
456
+ return blocked.response;
410
457
  const format = args.format ?? 'svg';
411
458
  const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
412
459
  const inputs = {
@@ -417,45 +464,91 @@ export function createMcpServer(opts = {}) {
417
464
  width: args.width,
418
465
  pngScale: args.scale,
419
466
  };
420
- if (format === 'png') {
467
+ const host = createNodeHostEnv(filePath);
468
+ const appActive = args.preview === true || clientSupportsAppsUi(server);
469
+ // Skip the full render when the bytes won't be used: apps host with
470
+ // no write-to-disk path and no review attachment requested.
471
+ const needsRender = !appActive || !!args.output || args.review === true;
472
+ if (needsRender && (format === 'png' || args.review === true)) {
421
473
  const result = await resolveFonts({ headless: true });
422
474
  inputs.fonts = { sans: result.sans, mono: result.mono };
423
475
  }
424
- const host = createNodeHostEnv(filePath);
425
- const bytes = await exportDocument(source, format, inputs, host);
476
+ const bytes = needsRender
477
+ ? await exportDocument(source, format, inputs, host)
478
+ : new Uint8Array(0);
426
479
  const shareUrl = args.share
427
480
  ? (buildShareLink({ source, share: true }) ?? undefined)
428
481
  : undefined;
429
- // Optional MCP Apps in-chat preview. Emitted alongside the normal
430
- // content so non-UI hosts still get the SVG/PNG; the bundle renders
431
- // the live SVG itself, so the preview is format-agnostic.
432
- const wantPreview = args.preview === true || clientSupportsAppsUi(server);
433
- const previewBlocks = wantPreview
434
- ? [
435
- previewResourceBlock({
436
- source,
437
- theme: args.theme,
438
- now: args.now,
439
- width: args.width,
440
- locale: 'en-US',
441
- }),
442
- ]
482
+ const insights = await collectMcpLayoutInsights({
483
+ source,
484
+ filePath,
485
+ today,
486
+ theme: args.theme ?? 'light',
487
+ width: args.width,
488
+ locale: 'en-US',
489
+ readFile: host.readSource,
490
+ doc: blocked.doc,
491
+ });
492
+ const previewPayload = {
493
+ source,
494
+ theme: args.theme,
495
+ now: args.now,
496
+ width: args.width,
497
+ locale: 'en-US',
498
+ };
499
+ const reviewBlocks = args.review === true
500
+ ? await buildReviewContentBlocks(source, format, bytes, inputs, host)
443
501
  : [];
502
+ const insightHintBlocks = insights.length > 0 ? [{ type: 'text', text: LAYOUT_INSIGHT_HINT }] : [];
503
+ const insightsField = insights.length > 0 ? { insights } : {};
444
504
  if (args.output) {
445
- const outAbs = resolveAndGuard(args.output, allowedRoot);
446
- await fs.mkdir(path.dirname(outAbs), { recursive: true });
447
- await fs.writeFile(outAbs, bytes);
448
- const structured = { format, path: outAbs, bytes: bytes.byteLength, shareUrl };
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) {
531
+ const structured = {
532
+ format,
533
+ shareUrl,
534
+ ...insightsField,
535
+ };
449
536
  return {
450
537
  content: [
451
- { type: 'text', text: JSON.stringify(structured, null, 2) },
452
- ...previewBlocks,
538
+ leanPreviewBlock(previewPayload),
539
+ ...insightHintBlocks,
540
+ ...reviewBlocks,
453
541
  ],
454
542
  structuredContent: structured,
455
543
  };
456
544
  }
457
545
  if (format === 'png') {
458
- const structured = { format, bytes: bytes.byteLength, shareUrl };
546
+ const structured = {
547
+ format,
548
+ bytes: bytes.byteLength,
549
+ shareUrl,
550
+ ...insightsField,
551
+ };
459
552
  return {
460
553
  content: [
461
554
  {
@@ -463,17 +556,19 @@ export function createMcpServer(opts = {}) {
463
556
  data: Buffer.from(bytes).toString('base64'),
464
557
  mimeType: 'image/png',
465
558
  },
466
- ...previewBlocks,
559
+ ...insightHintBlocks,
560
+ ...reviewBlocks,
467
561
  ],
468
562
  structuredContent: structured,
469
563
  };
470
564
  }
471
565
  const svgText = new TextDecoder('utf-8').decode(bytes);
472
- const structured = { format, shareUrl };
566
+ const structured = { format, shareUrl, ...insightsField };
473
567
  return {
474
568
  content: [
475
569
  { type: 'text', text: svgText, mimeType: 'image/svg+xml' },
476
- ...previewBlocks,
570
+ ...insightHintBlocks,
571
+ ...reviewBlocks,
477
572
  ],
478
573
  structuredContent: structured,
479
574
  };
@@ -484,14 +579,22 @@ export function createMcpServer(opts = {}) {
484
579
  description: 'Export a .nowline roadmap to any of the eight canonical formats. Byte-identical to `nowline -f <format>` for the same source and inputs.',
485
580
  inputSchema: z.object({
486
581
  source: z.string().optional().describe('Inline .nowline source text.'),
487
- path: z.string().optional().describe('Path to the .nowline file.'),
582
+ path: z
583
+ .string()
584
+ .optional()
585
+ .describe('Real local filesystem path to the .nowline file (e.g. /Users/name/Desktop/foo.nowline). ' +
586
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
587
+ 'those do not exist on the host filesystem. Pass `source` instead.'),
488
588
  format: z
489
589
  .enum(EXPORT_FORMATS)
490
590
  .describe('Export format: pdf, html, mermaid, xlsx, msproj, or png.'),
491
591
  output: z
492
592
  .string()
493
593
  .optional()
494
- .describe('Path to write the output file. Required for binary formats.'),
594
+ .describe('Real local filesystem path to write the output (e.g. /Users/name/Desktop/roadmap.pdf). ' +
595
+ 'Required for binary formats (pdf, xlsx, msproj, png). ' +
596
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
597
+ 'those do not exist on the host filesystem.'),
495
598
  now: z.string().optional().describe('Now-line date as YYYY-MM-DD (UTC).'),
496
599
  theme: z.enum(['light', 'dark', 'grayscale']).optional(),
497
600
  scale: z.number().optional().describe('PNG scale factor.'),
@@ -511,9 +614,22 @@ export function createMcpServer(opts = {}) {
511
614
  .describe('When true, include a shareUrl pointing to https://free.nowline.io/open.'),
512
615
  }),
513
616
  outputSchema: ExportOutputSchema,
514
- annotations: { readOnlyHint: true, idempotentHint: true },
617
+ annotations: readOnlyTool('Export Roadmap'),
515
618
  }, async (args) => {
516
- 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
+ }
630
+ const blocked = await diagnosticsErrorBlock(source, filePath);
631
+ if (!blocked.ok)
632
+ return blocked.response;
517
633
  const format = args.format;
518
634
  const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
519
635
  const inputs = {
@@ -539,14 +655,19 @@ export function createMcpServer(opts = {}) {
539
655
  const BINARY_FORMATS = new Set(['png', 'pdf', 'xlsx']);
540
656
  const isBinary = BINARY_FORMATS.has(format);
541
657
  if (args.output) {
542
- const outAbs = resolveAndGuard(args.output, allowedRoot);
543
- await fs.mkdir(path.dirname(outAbs), { recursive: true });
544
- await fs.writeFile(outAbs, bytes);
545
- const structured = { format, path: outAbs, bytes: bytes.byteLength, shareUrl };
546
- return {
547
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
548
- structuredContent: structured,
549
- };
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
+ }
550
671
  }
551
672
  if (isBinary) {
552
673
  const mimeMap = {
@@ -578,52 +699,62 @@ export function createMcpServer(opts = {}) {
578
699
  description: '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.',
579
700
  inputSchema: z.object({
580
701
  source: z.string().optional().describe('Inline source text to convert.'),
581
- path: z.string().optional().describe('Path to the source file.'),
702
+ path: z
703
+ .string()
704
+ .optional()
705
+ .describe('Real local filesystem path to the source file. ' +
706
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
707
+ 'pass `source` instead.'),
582
708
  to: z
583
709
  .enum(['json', 'nowline'])
584
710
  .describe('"json" — serialize .nowline text to JSON AST. "nowline" — pretty-print a JSON AST back to .nowline source.'),
585
711
  }),
586
712
  outputSchema: ConvertOutputSchema,
587
- annotations: { readOnlyHint: true, idempotentHint: true },
713
+ annotations: readOnlyTool('Convert Roadmap'),
588
714
  }, async (args) => {
589
- if (args.to === 'json') {
590
- const { source, filePath } = await sourceAndPath(args, allowedRoot);
591
- const host = createNodeHostEnv(filePath);
592
- const jsonBytes = await exportDocument(source, 'json', {
593
- sourcePath: filePath,
594
- today: todayUtc(),
595
- locale: 'en-US',
596
- theme: 'light',
597
- }, host);
598
- const result = new TextDecoder('utf-8').decode(jsonBytes);
599
- 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 };
600
743
  return {
601
744
  content: [{ type: 'text', text: result }],
602
745
  structuredContent: structured,
603
746
  };
604
747
  }
605
- // to: 'nowline' — input is a JSON AST string
606
- const jsonSource = args.source ??
607
- (args.path
608
- ? await fs.readFile(resolveAndGuard(args.path, allowedRoot), 'utf-8')
609
- : null);
610
- if (!jsonSource) {
611
- throw new Error('At least one of `source` or `path` is required.');
748
+ catch (err) {
749
+ return handleToolError(err, args.path);
612
750
  }
613
- const { ast } = parseNowlineJson(jsonSource, args.path ?? 'input.json');
614
- const result = printNowlineFile(ast);
615
- const structured = { to: 'nowline', result };
616
- return {
617
- content: [{ type: 'text', text: result }],
618
- structuredContent: structured,
619
- };
620
751
  });
621
752
  // ---- capabilities -------------------------------------------------------
622
753
  server.registerTool('capabilities', {
623
754
  description: 'Return all supported themes, icons, locales, export formats, and template names in a single response.',
624
755
  inputSchema: z.object({}),
625
756
  outputSchema: CapabilitiesOutputSchema,
626
- annotations: { readOnlyHint: true, idempotentHint: true },
757
+ annotations: readOnlyTool('View Roadmap Capabilities'),
627
758
  }, async () => {
628
759
  const structured = {
629
760
  themes: [...CAPABILITIES.themes],
@@ -642,7 +773,7 @@ export function createMcpServer(opts = {}) {
642
773
  description: 'List supported color themes: light, dark, grayscale.',
643
774
  inputSchema: z.object({}),
644
775
  outputSchema: ListItemsOutputSchema,
645
- annotations: { readOnlyHint: true, idempotentHint: true },
776
+ annotations: readOnlyTool('List Themes'),
646
777
  }, async () => {
647
778
  const structured = { items: [...CAPABILITIES.themes] };
648
779
  return {
@@ -655,7 +786,7 @@ export function createMcpServer(opts = {}) {
655
786
  description: 'List built-in capacity-icon names usable in the `capacity-icon:` style property.',
656
787
  inputSchema: z.object({}),
657
788
  outputSchema: ListItemsOutputSchema,
658
- annotations: { readOnlyHint: true, idempotentHint: true },
789
+ annotations: readOnlyTool('List Icons'),
659
790
  }, async () => {
660
791
  const structured = { items: [...CAPABILITIES.icons] };
661
792
  return {
@@ -668,7 +799,7 @@ export function createMcpServer(opts = {}) {
668
799
  description: 'List supported BCP-47 locale tags.',
669
800
  inputSchema: z.object({}),
670
801
  outputSchema: ListItemsOutputSchema,
671
- annotations: { readOnlyHint: true, idempotentHint: true },
802
+ annotations: readOnlyTool('List Locales'),
672
803
  }, async () => {
673
804
  const structured = { items: [...CAPABILITIES.locales] };
674
805
  return {
@@ -681,7 +812,7 @@ export function createMcpServer(opts = {}) {
681
812
  description: 'List all supported export formats (svg, png, pdf, html, mermaid, xlsx, msproj, json).',
682
813
  inputSchema: z.object({}),
683
814
  outputSchema: ListItemsOutputSchema,
684
- annotations: { readOnlyHint: true, idempotentHint: true },
815
+ annotations: readOnlyTool('List Export Formats'),
685
816
  }, async () => {
686
817
  const structured = { items: [...CAPABILITIES.formats] };
687
818
  return {
@@ -694,7 +825,7 @@ export function createMcpServer(opts = {}) {
694
825
  description: 'List built-in template names usable with `nowline --init --template`.',
695
826
  inputSchema: z.object({}),
696
827
  outputSchema: ListItemsOutputSchema,
697
- annotations: { readOnlyHint: true, idempotentHint: true },
828
+ annotations: readOnlyTool('List Templates'),
698
829
  }, async () => {
699
830
  const structured = { items: [...CAPABILITIES.templates] };
700
831
  return {
@@ -702,9 +833,128 @@ export function createMcpServer(opts = {}) {
702
833
  structuredContent: structured,
703
834
  };
704
835
  });
836
+ // ---- reference / examples / schema (discovery tools) --------------------
837
+ server.registerTool('reference', {
838
+ description: 'Return the Nowline DSL reference (condensed cheatsheet or full man page). Callable alternative to the nowline://reference resource.',
839
+ inputSchema: z.object({
840
+ format: z
841
+ .enum(['condensed', 'full'])
842
+ .optional()
843
+ .describe('Reference format. Defaults to condensed.'),
844
+ }),
845
+ outputSchema: ReferenceOutputSchema,
846
+ annotations: readOnlyTool('View Roadmap Reference'),
847
+ }, async (args) => {
848
+ const format = args.format ?? 'condensed';
849
+ const text = format === 'full' ? REFERENCE_MAN_PAGE : REFERENCE_CHEATSHEET;
850
+ const structured = { format, text };
851
+ return {
852
+ content: [{ type: 'text', text }],
853
+ structuredContent: structured,
854
+ };
855
+ });
856
+ server.registerTool('examples', {
857
+ description: 'Return canonical .nowline example sources. Callable alternative to the nowline://examples resource.',
858
+ inputSchema: z.object({
859
+ name: z
860
+ .string()
861
+ .optional()
862
+ .describe('Example name. Omit for the catalog plus minimal inline.'),
863
+ }),
864
+ outputSchema: ExamplesOutputSchema,
865
+ annotations: readOnlyTool('View Roadmap Examples'),
866
+ }, async (args) => {
867
+ const exampleNames = EXAMPLES.map((e) => exampleShortName(e.name));
868
+ if (args.name) {
869
+ const ex = findExample(args.name);
870
+ if (!ex) {
871
+ return {
872
+ content: [
873
+ {
874
+ type: 'text',
875
+ text: JSON.stringify({
876
+ error: `Unknown example "${args.name}".`,
877
+ names: exampleNames,
878
+ }),
879
+ },
880
+ ],
881
+ isError: true,
882
+ };
883
+ }
884
+ const structured = { name: exampleShortName(ex.name), source: ex.content };
885
+ return {
886
+ content: [{ type: 'text', text: ex.content }],
887
+ structuredContent: structured,
888
+ };
889
+ }
890
+ const minimal = findExample('minimal') ?? EXAMPLES[0];
891
+ const structured = {
892
+ names: exampleNames,
893
+ name: exampleShortName(minimal.name),
894
+ source: minimal.content,
895
+ };
896
+ return {
897
+ content: [
898
+ {
899
+ type: 'text',
900
+ text: `# Examples\n\n${exampleNames.map((n) => `- ${n}`).join('\n')}\n\n## ${structured.name}\n\n${minimal.content}`,
901
+ },
902
+ ],
903
+ structuredContent: structured,
904
+ };
905
+ });
906
+ server.registerTool('schema', {
907
+ description: 'Return the structured Nowline DSL key vocabulary (directive keys, entity types, item properties).',
908
+ inputSchema: z.object({}),
909
+ outputSchema: SchemaOutputSchema,
910
+ annotations: readOnlyTool('View Roadmap Schema'),
911
+ }, async () => {
912
+ const structured = {
913
+ directiveKeys: [...SCHEMA_VOCABULARY.directiveKeys],
914
+ entityTypes: [...SCHEMA_VOCABULARY.entityTypes],
915
+ itemPropertyKeys: [...SCHEMA_VOCABULARY.itemPropertyKeys],
916
+ };
917
+ return {
918
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
919
+ structuredContent: structured,
920
+ };
921
+ });
705
922
  return server;
706
923
  }
707
- // ---- Helpers ----------------------------------------------------------------
924
+ async function buildReviewContentBlocks(source, format, artifactBytes, inputs, host) {
925
+ const blocks = [
926
+ {
927
+ type: 'text',
928
+ text: 'Review this raster for layout issues (truncated labels, crowded lanes, ' +
929
+ 'now-line position) before finalizing.',
930
+ },
931
+ ];
932
+ const artifactWidth = inputs.width ?? DEFAULT_RENDER_WIDTH;
933
+ let inspectionBytes;
934
+ if (format === 'png' && artifactWidth <= REVIEW_MAX_WIDTH) {
935
+ inspectionBytes = artifactBytes;
936
+ }
937
+ else {
938
+ const reviewInputs = {
939
+ ...inputs,
940
+ width: Math.min(artifactWidth, REVIEW_MAX_WIDTH),
941
+ pngScale: 1,
942
+ };
943
+ if (!reviewInputs.fonts) {
944
+ const fonts = await resolveFonts({ headless: true });
945
+ reviewInputs.fonts = { sans: fonts.sans, mono: fonts.mono };
946
+ }
947
+ inspectionBytes = await exportDocument(source, 'png', reviewInputs, host);
948
+ }
949
+ if (format !== 'png' || inspectionBytes.byteLength !== artifactBytes.byteLength) {
950
+ blocks.push({
951
+ type: 'image',
952
+ data: Buffer.from(inspectionBytes).toString('base64'),
953
+ mimeType: 'image/png',
954
+ });
955
+ }
956
+ return blocks;
957
+ }
708
958
  async function listNowlineFiles(dir, recursive) {
709
959
  const results = [];
710
960
  try {