@nowline/mcp 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/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/capabilities.d.ts +9 -0
  6. package/dist/capabilities.d.ts.map +1 -0
  7. package/dist/capabilities.js +17 -0
  8. package/dist/capabilities.js.map +1 -0
  9. package/dist/diagnostics.d.ts +59 -0
  10. package/dist/diagnostics.d.ts.map +1 -0
  11. package/dist/diagnostics.js +117 -0
  12. package/dist/diagnostics.js.map +1 -0
  13. package/dist/generated/resources.d.ts +2 -0
  14. package/dist/generated/resources.d.ts.map +1 -1
  15. package/dist/generated/resources.js +6 -1
  16. package/dist/generated/resources.js.map +1 -1
  17. package/dist/generated/ui-bundle.d.ts +5 -0
  18. package/dist/generated/ui-bundle.d.ts.map +1 -0
  19. package/dist/generated/ui-bundle.js +11 -0
  20. package/dist/generated/ui-bundle.js.map +1 -0
  21. package/dist/index.js +63 -12
  22. package/dist/index.js.map +1 -1
  23. package/dist/prompts.d.ts +3 -0
  24. package/dist/prompts.d.ts.map +1 -0
  25. package/dist/prompts.js +143 -0
  26. package/dist/prompts.js.map +1 -0
  27. package/dist/reference-cheatsheet.d.ts +2 -0
  28. package/dist/reference-cheatsheet.d.ts.map +1 -0
  29. package/dist/reference-cheatsheet.js +47 -0
  30. package/dist/reference-cheatsheet.js.map +1 -0
  31. package/dist/schema-vocab.d.ts +6 -0
  32. package/dist/schema-vocab.d.ts.map +1 -0
  33. package/dist/schema-vocab.js +55 -0
  34. package/dist/schema-vocab.js.map +1 -0
  35. package/dist/schemas.d.ts +148 -0
  36. package/dist/schemas.d.ts.map +1 -0
  37. package/dist/schemas.js +88 -0
  38. package/dist/schemas.js.map +1 -0
  39. package/dist/server.d.ts +2 -0
  40. package/dist/server.d.ts.map +1 -1
  41. package/dist/server.js +531 -121
  42. package/dist/server.js.map +1 -1
  43. package/dist/ui/entry.d.ts +2 -0
  44. package/dist/ui/entry.d.ts.map +1 -0
  45. package/dist/ui/entry.js +187 -0
  46. package/dist/ui/entry.js.map +1 -0
  47. package/dist/ui/payload.d.ts +30 -0
  48. package/dist/ui/payload.d.ts.map +1 -0
  49. package/dist/ui/payload.js +49 -0
  50. package/dist/ui/payload.js.map +1 -0
  51. package/package.json +15 -6
  52. package/src/branding.ts +26 -0
  53. package/src/capabilities.ts +25 -0
  54. package/src/diagnostics.ts +185 -0
  55. package/src/generated/resources.ts +7 -1
  56. package/src/generated/ui-bundle.ts +12 -0
  57. package/src/index.ts +75 -13
  58. package/src/prompts.ts +172 -0
  59. package/src/reference-cheatsheet.ts +47 -0
  60. package/src/schema-vocab.ts +55 -0
  61. package/src/schemas.ts +106 -0
  62. package/src/server.ts +725 -139
  63. package/src/ui/entry.ts +214 -0
  64. package/src/ui/payload.ts +63 -0
package/dist/server.js CHANGED
@@ -1,65 +1,65 @@
1
1
  // @nowline/mcp server factory.
2
2
  //
3
- // Creates and configures an McpServer instance with all eight tools and two
4
- // resources (nowline://reference + nowline://examples). The factory is shared
5
- // by the entry-point bin (npx @nowline/mcp) and the CLI's --mcp flag so both
6
- // paths expose an identical surface.
3
+ // Creates and configures an McpServer instance with all tools, resources, and
4
+ // prompts. The factory is shared by the entry-point bin (npx @nowline/mcp)
5
+ // and the CLI's --mcp flag so both paths expose an identical surface.
7
6
  //
8
- // Spec: specs/mcp.md. Plan: export_determinism s8 + s9.
7
+ // Spec: specs/mcp.md.
9
8
  import { promises as fs } from 'node:fs';
10
9
  import * as path from 'node:path';
10
+ import { getUiCapability, RESOURCE_MIME_TYPE, registerAppResource, registerAppTool, } from '@modelcontextprotocol/ext-apps/server';
11
11
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
- import { collectDocumentDiagnostics, createNowlineServices } from '@nowline/core';
12
+ import { parseNowlineJson, printNowlineFile } from '@nowline/core';
13
13
  import { exportDocument, } from '@nowline/export';
14
14
  import { resolveFonts } from '@nowline/export-core';
15
- import { URI } from 'langium';
15
+ import { buildShareLink } from '@nowline/share-link';
16
16
  import { z } from 'zod';
17
- import { EXAMPLES, REFERENCE_MAN_PAGE } from './generated/resources.js';
18
- function collectMcpDiagnostics(doc, filePath) {
19
- const raw = collectDocumentDiagnostics(doc);
20
- const out = [];
21
- for (const d of raw) {
22
- if (d.origin === 'lexer' || d.origin === 'parser') {
23
- out.push({
24
- file: filePath,
25
- line: 1,
26
- column: 1,
27
- severity: 'error',
28
- code: d.origin === 'lexer' ? 'lexing-error' : 'parsing-error',
29
- message: d.error.message,
30
- });
31
- }
32
- else {
33
- const diag = d.diagnostic;
34
- const range = diag.range;
35
- out.push({
36
- file: filePath,
37
- line: (range?.start.line ?? 0) + 1,
38
- column: (range?.start.character ?? 0) + 1,
39
- severity: diag.severity === 1 ? 'error' : 'warning',
40
- code: String(diag.code ?? 'unknown'),
41
- message: diag.message,
42
- });
43
- }
44
- }
45
- return out;
46
- }
47
- // ---- Langium services -------------------------------------------------------
48
- let cachedServices;
49
- let docCounter = 0;
50
- function getServices() {
51
- if (!cachedServices)
52
- cachedServices = createNowlineServices();
53
- return cachedServices;
17
+ import { NOWLINE_MCP_ICONS } from './branding.js';
18
+ import { CAPABILITIES } from './capabilities.js';
19
+ import { buildDocument, collectMcpDiagnostics, collectMcpLayoutInsights, DEFAULT_RENDER_WIDTH, diagnosticsErrorBlock, LAYOUT_INSIGHT_HINT, REVIEW_MAX_WIDTH, toolDescriptionWithSyntax, } from './diagnostics.js';
20
+ import { CONVERSIONS_GUIDE, EXAMPLES, REFERENCE_MAN_PAGE, } from './generated/resources.js';
21
+ import { PREVIEW_HTML } from './generated/ui-bundle.js';
22
+ import { registerPrompts } from './prompts.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';
26
+ // ---- MCP Apps UI (in-chat live preview) -------------------------------------
27
+ //
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).
34
+ /** SEP-1865 UI extension capability id; also probed under common short keys. */
35
+ const MCP_APPS_UI_CAPABILITY = 'io.modelcontextprotocol/ui';
36
+ /** Versioned URI doubles as a cache key — bump suffix on bundle changes. */
37
+ export const PREVIEW_UI_URI = 'ui://nowline/preview-v1';
38
+ function clientSupportsAppsUi(server) {
39
+ // SEP-1724 negotiated extensions: canonical id under `extensions`.
40
+ const caps = server.server.getClientCapabilities();
41
+ if (!caps)
42
+ return false;
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));
54
48
  }
55
- async function buildDocument(source) {
56
- const services = getServices();
57
- const uri = URI.parse(`memory:///mcp-${++docCounter}.nowline`);
58
- const doc = services.shared.workspace.LangiumDocumentFactory.fromString(source, uri);
59
- await services.shared.workspace.DocumentBuilder.build([doc], { validation: true });
60
- return doc;
49
+ function leanPreviewBlock(payload) {
50
+ return {
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
+ }),
60
+ };
61
61
  }
62
- // ---- Allowed-root enforcement -----------------------------------------------
62
+ // ---- Server factory ---------------------------------------------------------
63
63
  function resolveAndGuard(filePath, allowedRoot) {
64
64
  const abs = path.resolve(allowedRoot, filePath);
65
65
  const guard = path.resolve(allowedRoot);
@@ -84,7 +84,6 @@ function createNodeHostEnv(sourcePath) {
84
84
  return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
85
85
  },
86
86
  async loadWasm() {
87
- // Resolve resvg.wasm relative to @nowline/export-png/dist/ at runtime.
88
87
  const { createRequire } = await import('node:module');
89
88
  const req = createRequire(import.meta.url);
90
89
  const entry = req.resolve('@resvg/resvg-wasm');
@@ -100,6 +99,13 @@ function todayUtc() {
100
99
  const now = new Date();
101
100
  return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
102
101
  }
102
+ function exampleShortName(fullName) {
103
+ return fullName.endsWith('.nowline') ? fullName.slice(0, -'.nowline'.length) : fullName;
104
+ }
105
+ function findExample(name) {
106
+ const withExt = name.endsWith('.nowline') ? name : `${name}.nowline`;
107
+ return EXAMPLES.find((e) => e.name === withExt || e.name === name);
108
+ }
103
109
  async function sourceAndPath(args, allowedRoot) {
104
110
  if (args.source !== undefined && args.path === undefined) {
105
111
  return { source: args.source, filePath: path.join(allowedRoot, 'unnamed.nowline') };
@@ -115,7 +121,15 @@ export function createMcpServer(opts = {}) {
115
121
  const allowedRoot = opts.allowedRoot ?? process.cwd();
116
122
  const server = new McpServer({
117
123
  name: opts.name ?? 'nowline',
118
- version: opts.version ?? '0.5.1',
124
+ version: opts.version ?? '0.6.0',
125
+ icons: [...NOWLINE_MCP_ICONS],
126
+ }, {
127
+ instructions: 'Nowline manages roadmaps written in the .nowline plain-text DSL — NOT JSON or any other ' +
128
+ 'structured format. All `source` parameters expect `.nowline` DSL text (starts with `nowline v1`). ' +
129
+ 'Workflow: 1. call `reference` or `examples` to learn syntax → 2. write `.nowline` → ' +
130
+ '3. call `render` (validates + renders; or `validate` alone) → 4. fix errors keyed on `NL.E####` ' +
131
+ 'and re-render → 5. review returned layout `insights` (what reflowed) → 6. when uncertain, ' +
132
+ 'call `render` with `review:true` for a final visual check. JSON in `convert` is AST conversion only.',
119
133
  });
120
134
  // ---- Resources ----------------------------------------------------------
121
135
  server.registerResource('nowline-reference', 'nowline://reference', {
@@ -136,9 +150,34 @@ export function createMcpServer(opts = {}) {
136
150
  mimeType: 'text/plain',
137
151
  })),
138
152
  }));
153
+ server.registerResource('nowline-conversions', 'nowline://conversions', {
154
+ description: 'LLM-mediated conversion guide: how to translate Mermaid gantt, MS Project, Excel, Google Sheets timeline, and generic CSV into Nowline DSL.',
155
+ mimeType: 'text/plain',
156
+ }, async () => ({
157
+ contents: [
158
+ {
159
+ uri: 'nowline://conversions',
160
+ text: CONVERSIONS_GUIDE,
161
+ mimeType: 'text/plain',
162
+ },
163
+ ],
164
+ }));
165
+ registerAppResource(server, 'nowline-preview', PREVIEW_UI_URI, {
166
+ description: 'Interactive in-chat roadmap preview (MCP Apps). Hydrates via ontoolresult.',
167
+ }, async () => ({
168
+ contents: [
169
+ {
170
+ uri: PREVIEW_UI_URI,
171
+ mimeType: RESOURCE_MIME_TYPE,
172
+ text: PREVIEW_HTML,
173
+ },
174
+ ],
175
+ }));
176
+ // ---- Prompts ------------------------------------------------------------
177
+ registerPrompts(server);
139
178
  // ---- validate -----------------------------------------------------------
140
179
  server.registerTool('validate', {
141
- description: 'Parse and validate a .nowline roadmap. Returns ok=true and an empty diagnostics array if valid, or ok=false with structured diagnostics.',
180
+ description: toolDescriptionWithSyntax('Parse and validate a .nowline roadmap. Returns ok=true with optional layout insights when valid, or ok=false with structured diagnostics.'),
142
181
  inputSchema: z.object({
143
182
  source: z.string().optional().describe('Inline .nowline source text to validate.'),
144
183
  path: z
@@ -146,13 +185,32 @@ export function createMcpServer(opts = {}) {
146
185
  .optional()
147
186
  .describe('Absolute or relative path to a .nowline file to validate.'),
148
187
  }),
188
+ outputSchema: ValidateOutputSchema,
189
+ annotations: { readOnlyHint: true, idempotentHint: true },
149
190
  }, async (args) => {
150
191
  const { source, filePath } = await sourceAndPath(args, allowedRoot);
151
192
  const doc = await buildDocument(source);
152
193
  const diagnostics = collectMcpDiagnostics(doc, filePath);
153
194
  const ok = diagnostics.every((d) => d.severity !== 'error');
195
+ const insights = ok
196
+ ? await collectMcpLayoutInsights({
197
+ source,
198
+ filePath,
199
+ today: todayUtc(),
200
+ locale: 'en-US',
201
+ readFile: createNodeHostEnv(filePath).readSource,
202
+ doc,
203
+ })
204
+ : [];
205
+ const structured = { ok, diagnostics, ...(insights.length > 0 ? { insights } : {}) };
154
206
  return {
155
- content: [{ type: 'text', text: JSON.stringify({ ok, diagnostics }, null, 2) }],
207
+ content: [
208
+ { type: 'text', text: JSON.stringify(structured, null, 2) },
209
+ ...(insights.length > 0
210
+ ? [{ type: 'text', text: LAYOUT_INSIGHT_HINT }]
211
+ : []),
212
+ ],
213
+ structuredContent: structured,
156
214
  };
157
215
  });
158
216
  // ---- read ---------------------------------------------------------------
@@ -163,75 +221,61 @@ export function createMcpServer(opts = {}) {
163
221
  .string()
164
222
  .describe('Absolute or relative path to the .nowline file to read.'),
165
223
  }),
224
+ outputSchema: ReadOutputSchema,
225
+ annotations: { readOnlyHint: true, idempotentHint: true },
166
226
  }, async (args) => {
167
227
  const abs = resolveAndGuard(args.path, allowedRoot);
168
228
  const source = await fs.readFile(abs, 'utf-8');
229
+ const structured = { path: abs, source };
169
230
  return {
170
- content: [
171
- {
172
- type: 'text',
173
- text: JSON.stringify({ path: abs, source }, null, 2),
174
- },
175
- ],
231
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
232
+ structuredContent: structured,
176
233
  };
177
234
  });
178
235
  // ---- create -------------------------------------------------------------
179
236
  server.registerTool('create', {
180
- description: 'Write a new .nowline file after validation. Overwrites if the path already exists.',
237
+ description: toolDescriptionWithSyntax('Write a new .nowline file after validation. Overwrites if the path already exists.'),
181
238
  inputSchema: z.object({
182
239
  path: z.string().describe('Absolute or relative path to write the .nowline file.'),
183
240
  source: z.string().describe('The .nowline source text to write.'),
184
241
  }),
242
+ outputSchema: CreateOutputSchema,
243
+ // Overwrites silently → destructive; same source always produces same file → idempotent.
244
+ annotations: { destructiveHint: true, idempotentHint: true },
185
245
  }, async (args) => {
186
246
  const abs = resolveAndGuard(args.path, allowedRoot);
187
- const doc = await buildDocument(args.source);
188
- const diagnostics = collectMcpDiagnostics(doc, abs);
189
- const errors = diagnostics.filter((d) => d.severity === 'error');
190
- if (errors.length > 0) {
191
- return {
192
- content: [
193
- {
194
- type: 'text',
195
- text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
196
- },
197
- ],
198
- isError: true,
199
- };
200
- }
247
+ const blocked = await diagnosticsErrorBlock(args.source, abs);
248
+ if (!blocked.ok)
249
+ return blocked.response;
201
250
  await fs.mkdir(path.dirname(abs), { recursive: true });
202
251
  await fs.writeFile(abs, args.source, 'utf-8');
252
+ const structured = { ok: true, path: abs };
203
253
  return {
204
- content: [{ type: 'text', text: JSON.stringify({ ok: true, path: abs }, null, 2) }],
254
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
255
+ structuredContent: structured,
205
256
  };
206
257
  });
207
258
  // ---- update -------------------------------------------------------------
208
259
  server.registerTool('update', {
209
- description: 'Replace an existing .nowline file after validation.',
260
+ description: toolDescriptionWithSyntax('Replace an existing .nowline file after validation.'),
210
261
  inputSchema: z.object({
211
262
  path: z
212
263
  .string()
213
264
  .describe('Absolute or relative path of the .nowline file to update.'),
214
265
  source: z.string().describe('The new .nowline source text.'),
215
266
  }),
267
+ outputSchema: UpdateOutputSchema,
268
+ annotations: { idempotentHint: true },
216
269
  }, async (args) => {
217
270
  const abs = resolveAndGuard(args.path, allowedRoot);
218
- const doc = await buildDocument(args.source);
219
- const diagnostics = collectMcpDiagnostics(doc, abs);
220
- const errors = diagnostics.filter((d) => d.severity === 'error');
221
- if (errors.length > 0) {
222
- return {
223
- content: [
224
- {
225
- type: 'text',
226
- text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
227
- },
228
- ],
229
- isError: true,
230
- };
231
- }
271
+ const blocked = await diagnosticsErrorBlock(args.source, abs);
272
+ if (!blocked.ok)
273
+ return blocked.response;
232
274
  await fs.writeFile(abs, args.source, 'utf-8');
275
+ const structured = { ok: true, path: abs };
233
276
  return {
234
- content: [{ type: 'text', text: JSON.stringify({ ok: true, path: abs }, null, 2) }],
277
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
278
+ structuredContent: structured,
235
279
  };
236
280
  });
237
281
  // ---- delete -------------------------------------------------------------
@@ -242,11 +286,15 @@ export function createMcpServer(opts = {}) {
242
286
  .string()
243
287
  .describe('Absolute or relative path of the .nowline file to delete.'),
244
288
  }),
289
+ outputSchema: DeleteOutputSchema,
290
+ annotations: { destructiveHint: true },
245
291
  }, async (args) => {
246
292
  const abs = resolveAndGuard(args.path, allowedRoot);
247
293
  await fs.unlink(abs);
294
+ const structured = { path: abs };
248
295
  return {
249
- content: [{ type: 'text', text: JSON.stringify({ path: abs }, null, 2) }],
296
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
297
+ structuredContent: structured,
250
298
  };
251
299
  });
252
300
  // ---- list ---------------------------------------------------------------
@@ -262,20 +310,30 @@ export function createMcpServer(opts = {}) {
262
310
  .optional()
263
311
  .describe('Whether to scan subdirectories. Defaults to false.'),
264
312
  }),
313
+ outputSchema: ListOutputSchema,
314
+ annotations: { readOnlyHint: true, idempotentHint: true },
265
315
  }, async (args) => {
266
316
  const dir = args.directory ? resolveAndGuard(args.directory, allowedRoot) : allowedRoot;
267
317
  const recursive = args.recursive ?? false;
268
318
  const paths = await listNowlineFiles(dir, recursive);
319
+ const structured = { paths };
269
320
  return {
270
- content: [{ type: 'text', text: JSON.stringify({ paths }, null, 2) }],
321
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
322
+ structuredContent: structured,
271
323
  };
272
324
  });
273
325
  // ---- render -------------------------------------------------------------
274
- server.registerTool('render', {
275
- 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.',
326
+ registerAppTool(server, 'render', {
327
+ description: toolDescriptionWithSyntax('Validate then render a .nowline roadmap to SVG or PNG (combined validate+render+share). ' +
328
+ 'Returns structured diagnostics on error-severity input instead of a raw kernel error.'),
276
329
  inputSchema: z.object({
277
330
  source: z.string().optional().describe('Inline .nowline source text.'),
278
- path: z.string().optional().describe('Path to the .nowline file.'),
331
+ path: z
332
+ .string()
333
+ .optional()
334
+ .describe('Real local filesystem path to the .nowline file (e.g. /Users/name/Desktop/foo.nowline). ' +
335
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
336
+ 'those do not exist on the host filesystem. Pass `source` instead.'),
279
337
  format: z
280
338
  .enum(['svg', 'png'])
281
339
  .optional()
@@ -296,10 +354,33 @@ export function createMcpServer(opts = {}) {
296
354
  output: z
297
355
  .string()
298
356
  .optional()
299
- .describe('Write output to this path instead of returning inline.'),
357
+ .describe('Real local filesystem path to write the output file (e.g. /Users/name/Desktop/roadmap.svg). ' +
358
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
359
+ 'omit this parameter to receive output inline instead.'),
360
+ share: z
361
+ .boolean()
362
+ .optional()
363
+ .describe('When true, include a shareUrl pointing to https://free.nowline.io/open.'),
364
+ review: z
365
+ .boolean()
366
+ .optional()
367
+ .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.'),
368
+ preview: z
369
+ .boolean()
370
+ .optional()
371
+ .describe('When true, force the in-chat MCP Apps preview. On MCP Apps hosts the preview auto-renders via _meta.ui without this flag.'),
300
372
  }),
373
+ outputSchema: RenderOutputSchema,
374
+ annotations: { readOnlyHint: true, idempotentHint: true },
375
+ _meta: {
376
+ ui: { resourceUri: PREVIEW_UI_URI },
377
+ 'openai/outputTemplate': PREVIEW_UI_URI,
378
+ },
301
379
  }, async (args) => {
302
380
  const { source, filePath } = await sourceAndPath(args, allowedRoot);
381
+ const blocked = await diagnosticsErrorBlock(source, filePath);
382
+ if (!blocked.ok)
383
+ return blocked.response;
303
384
  const format = args.format ?? 'svg';
304
385
  const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
305
386
  const inputs = {
@@ -310,26 +391,81 @@ export function createMcpServer(opts = {}) {
310
391
  width: args.width,
311
392
  pngScale: args.scale,
312
393
  };
313
- if (format === 'png') {
394
+ const host = createNodeHostEnv(filePath);
395
+ const appActive = args.preview === true || clientSupportsAppsUi(server);
396
+ // Skip the full render when the bytes won't be used: apps host with
397
+ // no write-to-disk path and no review attachment requested.
398
+ const needsRender = !appActive || !!args.output || args.review === true;
399
+ if (needsRender && (format === 'png' || args.review === true)) {
314
400
  const result = await resolveFonts({ headless: true });
315
401
  inputs.fonts = { sans: result.sans, mono: result.mono };
316
402
  }
317
- const host = createNodeHostEnv(filePath);
318
- const bytes = await exportDocument(source, format, inputs, host);
403
+ const bytes = needsRender
404
+ ? await exportDocument(source, format, inputs, host)
405
+ : new Uint8Array(0);
406
+ const shareUrl = args.share
407
+ ? (buildShareLink({ source, share: true }) ?? undefined)
408
+ : undefined;
409
+ const insights = await collectMcpLayoutInsights({
410
+ source,
411
+ filePath,
412
+ today,
413
+ theme: args.theme ?? 'light',
414
+ width: args.width,
415
+ locale: 'en-US',
416
+ readFile: host.readSource,
417
+ doc: blocked.doc,
418
+ });
419
+ const previewPayload = {
420
+ source,
421
+ theme: args.theme,
422
+ now: args.now,
423
+ width: args.width,
424
+ locale: 'en-US',
425
+ };
426
+ const reviewBlocks = args.review === true
427
+ ? await buildReviewContentBlocks(source, format, bytes, inputs, host)
428
+ : [];
429
+ const insightHintBlocks = insights.length > 0 ? [{ type: 'text', text: LAYOUT_INSIGHT_HINT }] : [];
430
+ const insightsField = insights.length > 0 ? { insights } : {};
319
431
  if (args.output) {
320
432
  const outAbs = resolveAndGuard(args.output, allowedRoot);
321
433
  await fs.mkdir(path.dirname(outAbs), { recursive: true });
322
434
  await fs.writeFile(outAbs, bytes);
435
+ const structured = {
436
+ format,
437
+ path: outAbs,
438
+ bytes: bytes.byteLength,
439
+ shareUrl,
440
+ ...insightsField,
441
+ };
323
442
  return {
324
443
  content: [
325
- {
326
- type: 'text',
327
- text: JSON.stringify({ path: outAbs, bytes: bytes.byteLength }),
328
- },
444
+ ...(appActive ? [leanPreviewBlock(previewPayload)] : []),
445
+ { type: 'text', text: JSON.stringify(structured, null, 2) },
446
+ ...insightHintBlocks,
447
+ ...reviewBlocks,
448
+ ],
449
+ structuredContent: structured,
450
+ };
451
+ }
452
+ if (appActive) {
453
+ const structured = {
454
+ format,
455
+ shareUrl,
456
+ ...insightsField,
457
+ };
458
+ return {
459
+ content: [
460
+ leanPreviewBlock(previewPayload),
461
+ ...insightHintBlocks,
462
+ ...reviewBlocks,
329
463
  ],
464
+ structuredContent: structured,
330
465
  };
331
466
  }
332
467
  if (format === 'png') {
468
+ const structured = { format, bytes: bytes.byteLength, shareUrl, ...insightsField };
333
469
  return {
334
470
  content: [
335
471
  {
@@ -337,17 +473,21 @@ export function createMcpServer(opts = {}) {
337
473
  data: Buffer.from(bytes).toString('base64'),
338
474
  mimeType: 'image/png',
339
475
  },
476
+ ...insightHintBlocks,
477
+ ...reviewBlocks,
340
478
  ],
479
+ structuredContent: structured,
341
480
  };
342
481
  }
482
+ const svgText = new TextDecoder('utf-8').decode(bytes);
483
+ const structured = { format, shareUrl, ...insightsField };
343
484
  return {
344
485
  content: [
345
- {
346
- type: 'text',
347
- text: new TextDecoder('utf-8').decode(bytes),
348
- mimeType: 'image/svg+xml',
349
- },
486
+ { type: 'text', text: svgText, mimeType: 'image/svg+xml' },
487
+ ...insightHintBlocks,
488
+ ...reviewBlocks,
350
489
  ],
490
+ structuredContent: structured,
351
491
  };
352
492
  });
353
493
  // ---- export -------------------------------------------------------------
@@ -356,14 +496,22 @@ export function createMcpServer(opts = {}) {
356
496
  description: 'Export a .nowline roadmap to any of the eight canonical formats. Byte-identical to `nowline -f <format>` for the same source and inputs.',
357
497
  inputSchema: z.object({
358
498
  source: z.string().optional().describe('Inline .nowline source text.'),
359
- path: z.string().optional().describe('Path to the .nowline file.'),
499
+ path: z
500
+ .string()
501
+ .optional()
502
+ .describe('Real local filesystem path to the .nowline file (e.g. /Users/name/Desktop/foo.nowline). ' +
503
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
504
+ 'those do not exist on the host filesystem. Pass `source` instead.'),
360
505
  format: z
361
506
  .enum(EXPORT_FORMATS)
362
507
  .describe('Export format: pdf, html, mermaid, xlsx, msproj, or png.'),
363
508
  output: z
364
509
  .string()
365
510
  .optional()
366
- .describe('Path to write the output file. Required for binary formats.'),
511
+ .describe('Real local filesystem path to write the output (e.g. /Users/name/Desktop/roadmap.pdf). ' +
512
+ 'Required for binary formats (pdf, xlsx, msproj, png). ' +
513
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
514
+ 'those do not exist on the host filesystem.'),
367
515
  now: z.string().optional().describe('Now-line date as YYYY-MM-DD (UTC).'),
368
516
  theme: z.enum(['light', 'dark', 'grayscale']).optional(),
369
517
  scale: z.number().optional().describe('PNG scale factor.'),
@@ -377,9 +525,18 @@ export function createMcpServer(opts = {}) {
377
525
  .string()
378
526
  .optional()
379
527
  .describe('MS Project start date override (YYYY-MM-DD).'),
528
+ share: z
529
+ .boolean()
530
+ .optional()
531
+ .describe('When true, include a shareUrl pointing to https://free.nowline.io/open.'),
380
532
  }),
533
+ outputSchema: ExportOutputSchema,
534
+ annotations: { readOnlyHint: true, idempotentHint: true },
381
535
  }, async (args) => {
382
536
  const { source, filePath } = await sourceAndPath(args, allowedRoot);
537
+ const blocked = await diagnosticsErrorBlock(source, filePath);
538
+ if (!blocked.ok)
539
+ return blocked.response;
383
540
  const format = args.format;
384
541
  const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
385
542
  const inputs = {
@@ -399,19 +556,19 @@ export function createMcpServer(opts = {}) {
399
556
  }
400
557
  const host = createNodeHostEnv(filePath);
401
558
  const bytes = await exportDocument(source, format, inputs, host);
559
+ const shareUrl = args.share
560
+ ? (buildShareLink({ source, share: true }) ?? undefined)
561
+ : undefined;
402
562
  const BINARY_FORMATS = new Set(['png', 'pdf', 'xlsx']);
403
563
  const isBinary = BINARY_FORMATS.has(format);
404
564
  if (args.output) {
405
565
  const outAbs = resolveAndGuard(args.output, allowedRoot);
406
566
  await fs.mkdir(path.dirname(outAbs), { recursive: true });
407
567
  await fs.writeFile(outAbs, bytes);
568
+ const structured = { format, path: outAbs, bytes: bytes.byteLength, shareUrl };
408
569
  return {
409
- content: [
410
- {
411
- type: 'text',
412
- text: JSON.stringify({ path: outAbs, bytes: bytes.byteLength }),
413
- },
414
- ],
570
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
571
+ structuredContent: structured,
415
572
  };
416
573
  }
417
574
  if (isBinary) {
@@ -420,6 +577,7 @@ export function createMcpServer(opts = {}) {
420
577
  pdf: 'application/pdf',
421
578
  xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
422
579
  };
580
+ const structured = { format, bytes: bytes.byteLength, shareUrl };
423
581
  return {
424
582
  content: [
425
583
  {
@@ -428,20 +586,272 @@ export function createMcpServer(opts = {}) {
428
586
  mimeType: mimeMap[format] ?? 'application/octet-stream',
429
587
  },
430
588
  ],
589
+ structuredContent: structured,
431
590
  };
432
591
  }
592
+ const text = new TextDecoder('utf-8').decode(bytes);
593
+ const structured = { format, shareUrl };
594
+ return {
595
+ content: [{ type: 'text', text }],
596
+ structuredContent: structured,
597
+ };
598
+ });
599
+ // ---- convert ------------------------------------------------------------
600
+ server.registerTool('convert', {
601
+ 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.',
602
+ inputSchema: z.object({
603
+ source: z.string().optional().describe('Inline source text to convert.'),
604
+ path: z
605
+ .string()
606
+ .optional()
607
+ .describe('Real local filesystem path to the source file. ' +
608
+ 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
609
+ 'pass `source` instead.'),
610
+ to: z
611
+ .enum(['json', 'nowline'])
612
+ .describe('"json" — serialize .nowline text to JSON AST. "nowline" — pretty-print a JSON AST back to .nowline source.'),
613
+ }),
614
+ outputSchema: ConvertOutputSchema,
615
+ annotations: { readOnlyHint: true, idempotentHint: true },
616
+ }, async (args) => {
617
+ if (args.to === 'json') {
618
+ const { source, filePath } = await sourceAndPath(args, allowedRoot);
619
+ const host = createNodeHostEnv(filePath);
620
+ const jsonBytes = await exportDocument(source, 'json', {
621
+ sourcePath: filePath,
622
+ today: todayUtc(),
623
+ locale: 'en-US',
624
+ theme: 'light',
625
+ }, host);
626
+ const result = new TextDecoder('utf-8').decode(jsonBytes);
627
+ const structured = { to: 'json', result };
628
+ return {
629
+ content: [{ type: 'text', text: result }],
630
+ structuredContent: structured,
631
+ };
632
+ }
633
+ // to: 'nowline' — input is a JSON AST string
634
+ const jsonSource = args.source ??
635
+ (args.path
636
+ ? await fs.readFile(resolveAndGuard(args.path, allowedRoot), 'utf-8')
637
+ : null);
638
+ if (!jsonSource) {
639
+ throw new Error('At least one of `source` or `path` is required.');
640
+ }
641
+ const { ast } = parseNowlineJson(jsonSource, args.path ?? 'input.json');
642
+ const result = printNowlineFile(ast);
643
+ const structured = { to: 'nowline', result };
644
+ return {
645
+ content: [{ type: 'text', text: result }],
646
+ structuredContent: structured,
647
+ };
648
+ });
649
+ // ---- capabilities -------------------------------------------------------
650
+ server.registerTool('capabilities', {
651
+ description: 'Return all supported themes, icons, locales, export formats, and template names in a single response.',
652
+ inputSchema: z.object({}),
653
+ outputSchema: CapabilitiesOutputSchema,
654
+ annotations: { readOnlyHint: true, idempotentHint: true },
655
+ }, async () => {
656
+ const structured = {
657
+ themes: [...CAPABILITIES.themes],
658
+ icons: [...CAPABILITIES.icons],
659
+ locales: [...CAPABILITIES.locales],
660
+ formats: [...CAPABILITIES.formats],
661
+ templates: [...CAPABILITIES.templates],
662
+ };
663
+ return {
664
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
665
+ structuredContent: structured,
666
+ };
667
+ });
668
+ // ---- list-themes --------------------------------------------------------
669
+ server.registerTool('list-themes', {
670
+ description: 'List supported color themes: light, dark, grayscale.',
671
+ inputSchema: z.object({}),
672
+ outputSchema: ListItemsOutputSchema,
673
+ annotations: { readOnlyHint: true, idempotentHint: true },
674
+ }, async () => {
675
+ const structured = { items: [...CAPABILITIES.themes] };
676
+ return {
677
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
678
+ structuredContent: structured,
679
+ };
680
+ });
681
+ // ---- list-icons ---------------------------------------------------------
682
+ server.registerTool('list-icons', {
683
+ description: 'List built-in capacity-icon names usable in the `capacity-icon:` style property.',
684
+ inputSchema: z.object({}),
685
+ outputSchema: ListItemsOutputSchema,
686
+ annotations: { readOnlyHint: true, idempotentHint: true },
687
+ }, async () => {
688
+ const structured = { items: [...CAPABILITIES.icons] };
689
+ return {
690
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
691
+ structuredContent: structured,
692
+ };
693
+ });
694
+ // ---- list-locales -------------------------------------------------------
695
+ server.registerTool('list-locales', {
696
+ description: 'List supported BCP-47 locale tags.',
697
+ inputSchema: z.object({}),
698
+ outputSchema: ListItemsOutputSchema,
699
+ annotations: { readOnlyHint: true, idempotentHint: true },
700
+ }, async () => {
701
+ const structured = { items: [...CAPABILITIES.locales] };
702
+ return {
703
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
704
+ structuredContent: structured,
705
+ };
706
+ });
707
+ // ---- list-formats -------------------------------------------------------
708
+ server.registerTool('list-formats', {
709
+ description: 'List all supported export formats (svg, png, pdf, html, mermaid, xlsx, msproj, json).',
710
+ inputSchema: z.object({}),
711
+ outputSchema: ListItemsOutputSchema,
712
+ annotations: { readOnlyHint: true, idempotentHint: true },
713
+ }, async () => {
714
+ const structured = { items: [...CAPABILITIES.formats] };
715
+ return {
716
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
717
+ structuredContent: structured,
718
+ };
719
+ });
720
+ // ---- list-templates -----------------------------------------------------
721
+ server.registerTool('list-templates', {
722
+ description: 'List built-in template names usable with `nowline --init --template`.',
723
+ inputSchema: z.object({}),
724
+ outputSchema: ListItemsOutputSchema,
725
+ annotations: { readOnlyHint: true, idempotentHint: true },
726
+ }, async () => {
727
+ const structured = { items: [...CAPABILITIES.templates] };
728
+ return {
729
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
730
+ structuredContent: structured,
731
+ };
732
+ });
733
+ // ---- reference / examples / schema (discovery tools) --------------------
734
+ server.registerTool('reference', {
735
+ description: 'Return the Nowline DSL reference (condensed cheatsheet or full man page). Callable alternative to the nowline://reference resource.',
736
+ inputSchema: z.object({
737
+ format: z
738
+ .enum(['condensed', 'full'])
739
+ .optional()
740
+ .describe('Reference format. Defaults to condensed.'),
741
+ }),
742
+ outputSchema: ReferenceOutputSchema,
743
+ annotations: { readOnlyHint: true, idempotentHint: true },
744
+ }, async (args) => {
745
+ const format = args.format ?? 'condensed';
746
+ const text = format === 'full' ? REFERENCE_MAN_PAGE : REFERENCE_CHEATSHEET;
747
+ const structured = { format, text };
748
+ return {
749
+ content: [{ type: 'text', text }],
750
+ structuredContent: structured,
751
+ };
752
+ });
753
+ server.registerTool('examples', {
754
+ description: 'Return canonical .nowline example sources. Callable alternative to the nowline://examples resource.',
755
+ inputSchema: z.object({
756
+ name: z
757
+ .string()
758
+ .optional()
759
+ .describe('Example name. Omit for the catalog plus minimal inline.'),
760
+ }),
761
+ outputSchema: ExamplesOutputSchema,
762
+ annotations: { readOnlyHint: true, idempotentHint: true },
763
+ }, async (args) => {
764
+ const exampleNames = EXAMPLES.map((e) => exampleShortName(e.name));
765
+ if (args.name) {
766
+ const ex = findExample(args.name);
767
+ if (!ex) {
768
+ return {
769
+ content: [
770
+ {
771
+ type: 'text',
772
+ text: JSON.stringify({
773
+ error: `Unknown example "${args.name}".`,
774
+ names: exampleNames,
775
+ }),
776
+ },
777
+ ],
778
+ isError: true,
779
+ };
780
+ }
781
+ const structured = { name: exampleShortName(ex.name), source: ex.content };
782
+ return {
783
+ content: [{ type: 'text', text: ex.content }],
784
+ structuredContent: structured,
785
+ };
786
+ }
787
+ const minimal = findExample('minimal') ?? EXAMPLES[0];
788
+ const structured = {
789
+ names: exampleNames,
790
+ name: exampleShortName(minimal.name),
791
+ source: minimal.content,
792
+ };
433
793
  return {
434
794
  content: [
435
795
  {
436
796
  type: 'text',
437
- text: new TextDecoder('utf-8').decode(bytes),
797
+ text: `# Examples\n\n${exampleNames.map((n) => `- ${n}`).join('\n')}\n\n## ${structured.name}\n\n${minimal.content}`,
438
798
  },
439
799
  ],
800
+ structuredContent: structured,
801
+ };
802
+ });
803
+ server.registerTool('schema', {
804
+ description: 'Return the structured Nowline DSL key vocabulary (directive keys, entity types, item properties).',
805
+ inputSchema: z.object({}),
806
+ outputSchema: SchemaOutputSchema,
807
+ annotations: { readOnlyHint: true, idempotentHint: true },
808
+ }, async () => {
809
+ const structured = {
810
+ directiveKeys: [...SCHEMA_VOCABULARY.directiveKeys],
811
+ entityTypes: [...SCHEMA_VOCABULARY.entityTypes],
812
+ itemPropertyKeys: [...SCHEMA_VOCABULARY.itemPropertyKeys],
813
+ };
814
+ return {
815
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
816
+ structuredContent: structured,
440
817
  };
441
818
  });
442
819
  return server;
443
820
  }
444
- // ---- Helpers ----------------------------------------------------------------
821
+ async function buildReviewContentBlocks(source, format, artifactBytes, inputs, host) {
822
+ const blocks = [
823
+ {
824
+ type: 'text',
825
+ text: 'Review this raster for layout issues (truncated labels, crowded lanes, ' +
826
+ 'now-line position) before finalizing.',
827
+ },
828
+ ];
829
+ const artifactWidth = inputs.width ?? DEFAULT_RENDER_WIDTH;
830
+ let inspectionBytes;
831
+ if (format === 'png' && artifactWidth <= REVIEW_MAX_WIDTH) {
832
+ inspectionBytes = artifactBytes;
833
+ }
834
+ else {
835
+ const reviewInputs = {
836
+ ...inputs,
837
+ width: Math.min(artifactWidth, REVIEW_MAX_WIDTH),
838
+ pngScale: 1,
839
+ };
840
+ if (!reviewInputs.fonts) {
841
+ const fonts = await resolveFonts({ headless: true });
842
+ reviewInputs.fonts = { sans: fonts.sans, mono: fonts.mono };
843
+ }
844
+ inspectionBytes = await exportDocument(source, 'png', reviewInputs, host);
845
+ }
846
+ if (format !== 'png' || inspectionBytes.byteLength !== artifactBytes.byteLength) {
847
+ blocks.push({
848
+ type: 'image',
849
+ data: Buffer.from(inspectionBytes).toString('base64'),
850
+ mimeType: 'image/png',
851
+ });
852
+ }
853
+ return blocks;
854
+ }
445
855
  async function listNowlineFiles(dir, recursive) {
446
856
  const results = [];
447
857
  try {