@nowline/mcp 0.7.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.
- package/dist/branding.d.ts +20 -0
- package/dist/branding.d.ts.map +1 -0
- package/dist/branding.js +27 -0
- package/dist/branding.js.map +1 -0
- package/dist/diagnostics.d.ts +59 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +117 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/generated/ui-bundle.d.ts +2 -0
- package/dist/generated/ui-bundle.d.ts.map +1 -1
- package/dist/generated/ui-bundle.js +4 -3
- package/dist/generated/ui-bundle.js.map +1 -1
- package/dist/reference-cheatsheet.d.ts +2 -0
- package/dist/reference-cheatsheet.d.ts.map +1 -0
- package/dist/reference-cheatsheet.js +47 -0
- package/dist/reference-cheatsheet.js.map +1 -0
- package/dist/schema-vocab.d.ts +6 -0
- package/dist/schema-vocab.d.ts.map +1 -0
- package/dist/schema-vocab.js +55 -0
- package/dist/schema-vocab.js.map +1 -0
- package/dist/schemas.d.ts +52 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +24 -1
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +312 -165
- package/dist/server.js.map +1 -1
- package/dist/ui/entry.js +148 -110
- package/dist/ui/entry.js.map +1 -1
- package/dist/ui/payload.d.ts +30 -0
- package/dist/ui/payload.d.ts.map +1 -0
- package/dist/ui/payload.js +49 -0
- package/dist/ui/payload.js.map +1 -0
- package/package.json +11 -7
- package/src/branding.ts +26 -0
- package/src/diagnostics.ts +185 -0
- package/src/generated/ui-bundle.ts +5 -3
- package/src/reference-cheatsheet.ts +47 -0
- package/src/schema-vocab.ts +55 -0
- package/src/schemas.ts +28 -1
- package/src/server.ts +419 -200
- package/src/ui/entry.ts +167 -122
- package/src/ui/payload.ts +63 -0
package/dist/server.js
CHANGED
|
@@ -7,123 +7,59 @@
|
|
|
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 {
|
|
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 {
|
|
19
|
-
import {
|
|
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';
|
|
20
22
|
import { registerPrompts } from './prompts.js';
|
|
21
|
-
import {
|
|
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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
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: '
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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;
|
|
118
|
-
}
|
|
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;
|
|
125
|
-
}
|
|
126
|
-
// ---- Allowed-root enforcement -----------------------------------------------
|
|
62
|
+
// ---- Server factory ---------------------------------------------------------
|
|
127
63
|
function resolveAndGuard(filePath, allowedRoot) {
|
|
128
64
|
const abs = path.resolve(allowedRoot, filePath);
|
|
129
65
|
const guard = path.resolve(allowedRoot);
|
|
@@ -163,6 +99,13 @@ function todayUtc() {
|
|
|
163
99
|
const now = new Date();
|
|
164
100
|
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
165
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
|
+
}
|
|
166
109
|
async function sourceAndPath(args, allowedRoot) {
|
|
167
110
|
if (args.source !== undefined && args.path === undefined) {
|
|
168
111
|
return { source: args.source, filePath: path.join(allowedRoot, 'unnamed.nowline') };
|
|
@@ -179,6 +122,14 @@ export function createMcpServer(opts = {}) {
|
|
|
179
122
|
const server = new McpServer({
|
|
180
123
|
name: opts.name ?? 'nowline',
|
|
181
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.',
|
|
182
133
|
});
|
|
183
134
|
// ---- Resources ----------------------------------------------------------
|
|
184
135
|
server.registerResource('nowline-reference', 'nowline://reference', {
|
|
@@ -211,11 +162,22 @@ export function createMcpServer(opts = {}) {
|
|
|
211
162
|
},
|
|
212
163
|
],
|
|
213
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
|
+
}));
|
|
214
176
|
// ---- Prompts ------------------------------------------------------------
|
|
215
177
|
registerPrompts(server);
|
|
216
178
|
// ---- validate -----------------------------------------------------------
|
|
217
179
|
server.registerTool('validate', {
|
|
218
|
-
description: 'Parse and validate a .nowline roadmap. Returns ok=true
|
|
180
|
+
description: toolDescriptionWithSyntax('Parse and validate a .nowline roadmap. Returns ok=true with optional layout insights when valid, or ok=false with structured diagnostics.'),
|
|
219
181
|
inputSchema: z.object({
|
|
220
182
|
source: z.string().optional().describe('Inline .nowline source text to validate.'),
|
|
221
183
|
path: z
|
|
@@ -230,9 +192,24 @@ export function createMcpServer(opts = {}) {
|
|
|
230
192
|
const doc = await buildDocument(source);
|
|
231
193
|
const diagnostics = collectMcpDiagnostics(doc, filePath);
|
|
232
194
|
const ok = diagnostics.every((d) => d.severity !== 'error');
|
|
233
|
-
const
|
|
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 } : {}) };
|
|
234
206
|
return {
|
|
235
|
-
content: [
|
|
207
|
+
content: [
|
|
208
|
+
{ type: 'text', text: JSON.stringify(structured, null, 2) },
|
|
209
|
+
...(insights.length > 0
|
|
210
|
+
? [{ type: 'text', text: LAYOUT_INSIGHT_HINT }]
|
|
211
|
+
: []),
|
|
212
|
+
],
|
|
236
213
|
structuredContent: structured,
|
|
237
214
|
};
|
|
238
215
|
});
|
|
@@ -257,7 +234,7 @@ export function createMcpServer(opts = {}) {
|
|
|
257
234
|
});
|
|
258
235
|
// ---- create -------------------------------------------------------------
|
|
259
236
|
server.registerTool('create', {
|
|
260
|
-
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.'),
|
|
261
238
|
inputSchema: z.object({
|
|
262
239
|
path: z.string().describe('Absolute or relative path to write the .nowline file.'),
|
|
263
240
|
source: z.string().describe('The .nowline source text to write.'),
|
|
@@ -267,20 +244,9 @@ export function createMcpServer(opts = {}) {
|
|
|
267
244
|
annotations: { destructiveHint: true, idempotentHint: true },
|
|
268
245
|
}, async (args) => {
|
|
269
246
|
const abs = resolveAndGuard(args.path, allowedRoot);
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (errors.length > 0) {
|
|
274
|
-
return {
|
|
275
|
-
content: [
|
|
276
|
-
{
|
|
277
|
-
type: 'text',
|
|
278
|
-
text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
|
|
279
|
-
},
|
|
280
|
-
],
|
|
281
|
-
isError: true,
|
|
282
|
-
};
|
|
283
|
-
}
|
|
247
|
+
const blocked = await diagnosticsErrorBlock(args.source, abs);
|
|
248
|
+
if (!blocked.ok)
|
|
249
|
+
return blocked.response;
|
|
284
250
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
285
251
|
await fs.writeFile(abs, args.source, 'utf-8');
|
|
286
252
|
const structured = { ok: true, path: abs };
|
|
@@ -291,7 +257,7 @@ export function createMcpServer(opts = {}) {
|
|
|
291
257
|
});
|
|
292
258
|
// ---- update -------------------------------------------------------------
|
|
293
259
|
server.registerTool('update', {
|
|
294
|
-
description: 'Replace an existing .nowline file after validation.',
|
|
260
|
+
description: toolDescriptionWithSyntax('Replace an existing .nowline file after validation.'),
|
|
295
261
|
inputSchema: z.object({
|
|
296
262
|
path: z
|
|
297
263
|
.string()
|
|
@@ -302,20 +268,9 @@ export function createMcpServer(opts = {}) {
|
|
|
302
268
|
annotations: { idempotentHint: true },
|
|
303
269
|
}, async (args) => {
|
|
304
270
|
const abs = resolveAndGuard(args.path, allowedRoot);
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (errors.length > 0) {
|
|
309
|
-
return {
|
|
310
|
-
content: [
|
|
311
|
-
{
|
|
312
|
-
type: 'text',
|
|
313
|
-
text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
|
|
314
|
-
},
|
|
315
|
-
],
|
|
316
|
-
isError: true,
|
|
317
|
-
};
|
|
318
|
-
}
|
|
271
|
+
const blocked = await diagnosticsErrorBlock(args.source, abs);
|
|
272
|
+
if (!blocked.ok)
|
|
273
|
+
return blocked.response;
|
|
319
274
|
await fs.writeFile(abs, args.source, 'utf-8');
|
|
320
275
|
const structured = { ok: true, path: abs };
|
|
321
276
|
return {
|
|
@@ -368,11 +323,17 @@ export function createMcpServer(opts = {}) {
|
|
|
368
323
|
};
|
|
369
324
|
});
|
|
370
325
|
// ---- render -------------------------------------------------------------
|
|
371
|
-
server
|
|
372
|
-
description: '
|
|
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.'),
|
|
373
329
|
inputSchema: z.object({
|
|
374
330
|
source: z.string().optional().describe('Inline .nowline source text.'),
|
|
375
|
-
path: z
|
|
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.'),
|
|
376
337
|
format: z
|
|
377
338
|
.enum(['svg', 'png'])
|
|
378
339
|
.optional()
|
|
@@ -393,20 +354,33 @@ export function createMcpServer(opts = {}) {
|
|
|
393
354
|
output: z
|
|
394
355
|
.string()
|
|
395
356
|
.optional()
|
|
396
|
-
.describe('
|
|
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.'),
|
|
397
360
|
share: z
|
|
398
361
|
.boolean()
|
|
399
362
|
.optional()
|
|
400
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.'),
|
|
401
368
|
preview: z
|
|
402
369
|
.boolean()
|
|
403
370
|
.optional()
|
|
404
|
-
.describe('When true,
|
|
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.'),
|
|
405
372
|
}),
|
|
406
373
|
outputSchema: RenderOutputSchema,
|
|
407
374
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
375
|
+
_meta: {
|
|
376
|
+
ui: { resourceUri: PREVIEW_UI_URI },
|
|
377
|
+
'openai/outputTemplate': PREVIEW_UI_URI,
|
|
378
|
+
},
|
|
408
379
|
}, async (args) => {
|
|
409
380
|
const { source, filePath } = await sourceAndPath(args, allowedRoot);
|
|
381
|
+
const blocked = await diagnosticsErrorBlock(source, filePath);
|
|
382
|
+
if (!blocked.ok)
|
|
383
|
+
return blocked.response;
|
|
410
384
|
const format = args.format ?? 'svg';
|
|
411
385
|
const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
|
|
412
386
|
const inputs = {
|
|
@@ -417,45 +391,81 @@ export function createMcpServer(opts = {}) {
|
|
|
417
391
|
width: args.width,
|
|
418
392
|
pngScale: args.scale,
|
|
419
393
|
};
|
|
420
|
-
|
|
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)) {
|
|
421
400
|
const result = await resolveFonts({ headless: true });
|
|
422
401
|
inputs.fonts = { sans: result.sans, mono: result.mono };
|
|
423
402
|
}
|
|
424
|
-
const
|
|
425
|
-
|
|
403
|
+
const bytes = needsRender
|
|
404
|
+
? await exportDocument(source, format, inputs, host)
|
|
405
|
+
: new Uint8Array(0);
|
|
426
406
|
const shareUrl = args.share
|
|
427
407
|
? (buildShareLink({ source, share: true }) ?? undefined)
|
|
428
408
|
: undefined;
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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)
|
|
443
428
|
: [];
|
|
429
|
+
const insightHintBlocks = insights.length > 0 ? [{ type: 'text', text: LAYOUT_INSIGHT_HINT }] : [];
|
|
430
|
+
const insightsField = insights.length > 0 ? { insights } : {};
|
|
444
431
|
if (args.output) {
|
|
445
432
|
const outAbs = resolveAndGuard(args.output, allowedRoot);
|
|
446
433
|
await fs.mkdir(path.dirname(outAbs), { recursive: true });
|
|
447
434
|
await fs.writeFile(outAbs, bytes);
|
|
448
|
-
const structured = {
|
|
435
|
+
const structured = {
|
|
436
|
+
format,
|
|
437
|
+
path: outAbs,
|
|
438
|
+
bytes: bytes.byteLength,
|
|
439
|
+
shareUrl,
|
|
440
|
+
...insightsField,
|
|
441
|
+
};
|
|
449
442
|
return {
|
|
450
443
|
content: [
|
|
444
|
+
...(appActive ? [leanPreviewBlock(previewPayload)] : []),
|
|
451
445
|
{ type: 'text', text: JSON.stringify(structured, null, 2) },
|
|
452
|
-
...
|
|
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,
|
|
453
463
|
],
|
|
454
464
|
structuredContent: structured,
|
|
455
465
|
};
|
|
456
466
|
}
|
|
457
467
|
if (format === 'png') {
|
|
458
|
-
const structured = { format, bytes: bytes.byteLength, shareUrl };
|
|
468
|
+
const structured = { format, bytes: bytes.byteLength, shareUrl, ...insightsField };
|
|
459
469
|
return {
|
|
460
470
|
content: [
|
|
461
471
|
{
|
|
@@ -463,17 +473,19 @@ export function createMcpServer(opts = {}) {
|
|
|
463
473
|
data: Buffer.from(bytes).toString('base64'),
|
|
464
474
|
mimeType: 'image/png',
|
|
465
475
|
},
|
|
466
|
-
...
|
|
476
|
+
...insightHintBlocks,
|
|
477
|
+
...reviewBlocks,
|
|
467
478
|
],
|
|
468
479
|
structuredContent: structured,
|
|
469
480
|
};
|
|
470
481
|
}
|
|
471
482
|
const svgText = new TextDecoder('utf-8').decode(bytes);
|
|
472
|
-
const structured = { format, shareUrl };
|
|
483
|
+
const structured = { format, shareUrl, ...insightsField };
|
|
473
484
|
return {
|
|
474
485
|
content: [
|
|
475
486
|
{ type: 'text', text: svgText, mimeType: 'image/svg+xml' },
|
|
476
|
-
...
|
|
487
|
+
...insightHintBlocks,
|
|
488
|
+
...reviewBlocks,
|
|
477
489
|
],
|
|
478
490
|
structuredContent: structured,
|
|
479
491
|
};
|
|
@@ -484,14 +496,22 @@ export function createMcpServer(opts = {}) {
|
|
|
484
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.',
|
|
485
497
|
inputSchema: z.object({
|
|
486
498
|
source: z.string().optional().describe('Inline .nowline source text.'),
|
|
487
|
-
path: z
|
|
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.'),
|
|
488
505
|
format: z
|
|
489
506
|
.enum(EXPORT_FORMATS)
|
|
490
507
|
.describe('Export format: pdf, html, mermaid, xlsx, msproj, or png.'),
|
|
491
508
|
output: z
|
|
492
509
|
.string()
|
|
493
510
|
.optional()
|
|
494
|
-
.describe('
|
|
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.'),
|
|
495
515
|
now: z.string().optional().describe('Now-line date as YYYY-MM-DD (UTC).'),
|
|
496
516
|
theme: z.enum(['light', 'dark', 'grayscale']).optional(),
|
|
497
517
|
scale: z.number().optional().describe('PNG scale factor.'),
|
|
@@ -514,6 +534,9 @@ export function createMcpServer(opts = {}) {
|
|
|
514
534
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
515
535
|
}, async (args) => {
|
|
516
536
|
const { source, filePath } = await sourceAndPath(args, allowedRoot);
|
|
537
|
+
const blocked = await diagnosticsErrorBlock(source, filePath);
|
|
538
|
+
if (!blocked.ok)
|
|
539
|
+
return blocked.response;
|
|
517
540
|
const format = args.format;
|
|
518
541
|
const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
|
|
519
542
|
const inputs = {
|
|
@@ -578,7 +601,12 @@ export function createMcpServer(opts = {}) {
|
|
|
578
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.',
|
|
579
602
|
inputSchema: z.object({
|
|
580
603
|
source: z.string().optional().describe('Inline source text to convert.'),
|
|
581
|
-
path: z
|
|
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.'),
|
|
582
610
|
to: z
|
|
583
611
|
.enum(['json', 'nowline'])
|
|
584
612
|
.describe('"json" — serialize .nowline text to JSON AST. "nowline" — pretty-print a JSON AST back to .nowline source.'),
|
|
@@ -702,9 +730,128 @@ export function createMcpServer(opts = {}) {
|
|
|
702
730
|
structuredContent: structured,
|
|
703
731
|
};
|
|
704
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
|
+
};
|
|
793
|
+
return {
|
|
794
|
+
content: [
|
|
795
|
+
{
|
|
796
|
+
type: 'text',
|
|
797
|
+
text: `# Examples\n\n${exampleNames.map((n) => `- ${n}`).join('\n')}\n\n## ${structured.name}\n\n${minimal.content}`,
|
|
798
|
+
},
|
|
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,
|
|
817
|
+
};
|
|
818
|
+
});
|
|
705
819
|
return server;
|
|
706
820
|
}
|
|
707
|
-
|
|
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
|
+
}
|
|
708
855
|
async function listNowlineFiles(dir, recursive) {
|
|
709
856
|
const results = [];
|
|
710
857
|
try {
|