@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/src/server.ts
CHANGED
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
|
|
9
9
|
import { promises as fs } from 'node:fs';
|
|
10
10
|
import * as path from 'node:path';
|
|
11
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
12
11
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
} from '@
|
|
12
|
+
getUiCapability,
|
|
13
|
+
RESOURCE_MIME_TYPE,
|
|
14
|
+
registerAppResource,
|
|
15
|
+
registerAppTool,
|
|
16
|
+
} from '@modelcontextprotocol/ext-apps/server';
|
|
17
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
|
+
import { parseNowlineJson, printNowlineFile } from '@nowline/core';
|
|
19
19
|
import {
|
|
20
20
|
type ExportFormat,
|
|
21
21
|
exportDocument,
|
|
@@ -24,52 +24,78 @@ import {
|
|
|
24
24
|
} from '@nowline/export';
|
|
25
25
|
import { resolveFonts } from '@nowline/export-core';
|
|
26
26
|
import { buildShareLink } from '@nowline/share-link';
|
|
27
|
-
import { URI } from 'langium';
|
|
28
27
|
import { z } from 'zod';
|
|
28
|
+
import { NOWLINE_MCP_ICONS } from './branding.js';
|
|
29
29
|
import { CAPABILITIES } from './capabilities.js';
|
|
30
|
-
import {
|
|
31
|
-
|
|
30
|
+
import {
|
|
31
|
+
buildDocument,
|
|
32
|
+
collectMcpDiagnostics,
|
|
33
|
+
collectMcpLayoutInsights,
|
|
34
|
+
DEFAULT_RENDER_WIDTH,
|
|
35
|
+
diagnosticsErrorBlock,
|
|
36
|
+
LAYOUT_INSIGHT_HINT,
|
|
37
|
+
REVIEW_MAX_WIDTH,
|
|
38
|
+
toolDescriptionWithSyntax,
|
|
39
|
+
} from './diagnostics.js';
|
|
40
|
+
import {
|
|
41
|
+
CONVERSIONS_GUIDE,
|
|
42
|
+
EXAMPLES,
|
|
43
|
+
type ExampleFile,
|
|
44
|
+
REFERENCE_MAN_PAGE,
|
|
45
|
+
} from './generated/resources.js';
|
|
46
|
+
import { PREVIEW_HTML } from './generated/ui-bundle.js';
|
|
32
47
|
import { registerPrompts } from './prompts.js';
|
|
48
|
+
import { REFERENCE_CHEATSHEET } from './reference-cheatsheet.js';
|
|
49
|
+
import { SCHEMA_VOCABULARY } from './schema-vocab.js';
|
|
33
50
|
import {
|
|
34
51
|
CapabilitiesOutputSchema,
|
|
35
52
|
ConvertOutputSchema,
|
|
36
53
|
CreateOutputSchema,
|
|
37
54
|
DeleteOutputSchema,
|
|
55
|
+
ExamplesOutputSchema,
|
|
38
56
|
ExportOutputSchema,
|
|
39
57
|
ListItemsOutputSchema,
|
|
40
58
|
ListOutputSchema,
|
|
41
59
|
ReadOutputSchema,
|
|
60
|
+
ReferenceOutputSchema,
|
|
42
61
|
RenderOutputSchema,
|
|
62
|
+
SchemaOutputSchema,
|
|
43
63
|
UpdateOutputSchema,
|
|
44
64
|
ValidateOutputSchema,
|
|
45
65
|
} from './schemas.js';
|
|
46
66
|
|
|
47
67
|
// ---- MCP Apps UI (in-chat live preview) -------------------------------------
|
|
48
68
|
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// so plain stdio operation is unchanged and non-UI hosts still receive the
|
|
56
|
-
// SVG/PNG content block alongside it (graceful degradation).
|
|
69
|
+
// Official MCP Apps model (SEP-1865): a pre-declared ui:// resource serves
|
|
70
|
+
// static HTML; the render tool declares _meta.ui.resourceUri; per-call data
|
|
71
|
+
// flows through the ontoolresult handshake. When an MCP Apps host is active,
|
|
72
|
+
// render returns a lean nowline.preview JSON payload (no inline SVG/PNG) so
|
|
73
|
+
// results stay under the host's ~150K inline cap. Non-apps hosts still get
|
|
74
|
+
// the full SVG/PNG inline (graceful degradation).
|
|
57
75
|
|
|
58
76
|
/** SEP-1865 UI extension capability id; also probed under common short keys. */
|
|
59
77
|
const MCP_APPS_UI_CAPABILITY = 'io.modelcontextprotocol/ui';
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const PREVIEW_UI_MIME = 'text/html;profile=mcp-app';
|
|
78
|
+
/** Versioned URI doubles as a cache key — bump suffix on bundle changes. */
|
|
79
|
+
export const PREVIEW_UI_URI = 'ui://nowline/preview-v1';
|
|
63
80
|
|
|
64
81
|
function clientSupportsAppsUi(server: McpServer): boolean {
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
82
|
+
// SEP-1724 negotiated extensions: canonical id under `extensions`.
|
|
83
|
+
const caps = server.server.getClientCapabilities() as
|
|
84
|
+
| {
|
|
85
|
+
experimental?: Record<string, unknown>;
|
|
86
|
+
extensions?: Record<string, unknown>;
|
|
87
|
+
}
|
|
70
88
|
| undefined;
|
|
71
|
-
if (!
|
|
72
|
-
|
|
89
|
+
if (!caps) return false;
|
|
90
|
+
if (getUiCapability(caps as Parameters<typeof getUiCapability>[0])) return true;
|
|
91
|
+
|
|
92
|
+
// Hosts also advertise under `experimental` or short `ui` / `apps` aliases.
|
|
93
|
+
const buckets = [caps.extensions, caps.experimental].filter(
|
|
94
|
+
(bucket): bucket is Record<string, unknown> => Boolean(bucket),
|
|
95
|
+
);
|
|
96
|
+
return buckets.some((bucket) =>
|
|
97
|
+
Boolean(bucket[MCP_APPS_UI_CAPABILITY] || bucket.ui || bucket.apps),
|
|
98
|
+
);
|
|
73
99
|
}
|
|
74
100
|
|
|
75
101
|
interface PreviewPayload {
|
|
@@ -78,112 +104,23 @@ interface PreviewPayload {
|
|
|
78
104
|
now?: string;
|
|
79
105
|
width?: number;
|
|
80
106
|
locale?: string;
|
|
81
|
-
showLinks?: boolean;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function buildPreviewHtml(payload: PreviewPayload): string {
|
|
85
|
-
// The payload (including the .nowline source) is injected as a JSON
|
|
86
|
-
// <script> block rather than interpolated into executable JS, so source
|
|
87
|
-
// text with quotes/backticks can't break out. Escaping `<` as \u003c keeps
|
|
88
|
-
// any embedded "</script>" from closing the block early; JSON.parse in the
|
|
89
|
-
// bundle decodes it back. The bundle injects its own stylesheet at runtime,
|
|
90
|
-
// so only the root-element sizing CSS is inlined here.
|
|
91
|
-
const data = JSON.stringify(payload).replace(/</g, '\\u003c');
|
|
92
|
-
return [
|
|
93
|
-
'<!doctype html>',
|
|
94
|
-
'<html lang="en">',
|
|
95
|
-
'<head>',
|
|
96
|
-
'<meta charset="utf-8" />',
|
|
97
|
-
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
98
|
-
'<title>Nowline preview</title>',
|
|
99
|
-
'<style>html,body,#nl-preview-root{margin:0;padding:0;height:100%;width:100%;overflow:hidden;}</style>',
|
|
100
|
-
'</head>',
|
|
101
|
-
'<body>',
|
|
102
|
-
'<div id="nl-preview-root"></div>',
|
|
103
|
-
`<script id="nl-preview-data" type="application/json">${data}</script>`,
|
|
104
|
-
`<script>${UI_BUNDLE}</script>`,
|
|
105
|
-
'</body>',
|
|
106
|
-
'</html>',
|
|
107
|
-
'',
|
|
108
|
-
].join('\n');
|
|
109
107
|
}
|
|
110
108
|
|
|
111
|
-
function
|
|
109
|
+
function leanPreviewBlock(payload: PreviewPayload) {
|
|
112
110
|
return {
|
|
113
|
-
type: '
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
111
|
+
type: 'text' as const,
|
|
112
|
+
text: JSON.stringify({
|
|
113
|
+
kind: 'nowline.preview',
|
|
114
|
+
source: payload.source,
|
|
115
|
+
theme: payload.theme,
|
|
116
|
+
now: payload.now,
|
|
117
|
+
width: payload.width,
|
|
118
|
+
locale: payload.locale,
|
|
119
|
+
}),
|
|
119
120
|
};
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
// ----
|
|
123
|
-
|
|
124
|
-
interface McpDiagnostic {
|
|
125
|
-
file: string;
|
|
126
|
-
line: number;
|
|
127
|
-
column: number;
|
|
128
|
-
severity: 'error' | 'warning';
|
|
129
|
-
code: string;
|
|
130
|
-
message: string;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function collectMcpDiagnostics(
|
|
134
|
-
doc: Awaited<ReturnType<typeof buildDocument>>,
|
|
135
|
-
filePath: string,
|
|
136
|
-
): McpDiagnostic[] {
|
|
137
|
-
const raw = collectDocumentDiagnostics(doc);
|
|
138
|
-
const out: McpDiagnostic[] = [];
|
|
139
|
-
for (const d of raw) {
|
|
140
|
-
if (d.origin === 'lexer' || d.origin === 'parser') {
|
|
141
|
-
out.push({
|
|
142
|
-
file: filePath,
|
|
143
|
-
line: 1,
|
|
144
|
-
column: 1,
|
|
145
|
-
severity: 'error',
|
|
146
|
-
code: d.origin === 'lexer' ? 'lexing-error' : 'parsing-error',
|
|
147
|
-
message: d.error.message,
|
|
148
|
-
});
|
|
149
|
-
} else {
|
|
150
|
-
const diag = d.diagnostic;
|
|
151
|
-
const range = diag.range;
|
|
152
|
-
out.push({
|
|
153
|
-
file: filePath,
|
|
154
|
-
line: (range?.start.line ?? 0) + 1,
|
|
155
|
-
column: (range?.start.character ?? 0) + 1,
|
|
156
|
-
severity: diag.severity === 1 ? 'error' : 'warning',
|
|
157
|
-
code: String(diag.code ?? 'unknown'),
|
|
158
|
-
message: diag.message,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return out;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// ---- Langium services -------------------------------------------------------
|
|
166
|
-
|
|
167
|
-
let cachedServices: ReturnType<typeof createNowlineServices> | undefined;
|
|
168
|
-
let docCounter = 0;
|
|
169
|
-
|
|
170
|
-
function getServices() {
|
|
171
|
-
if (!cachedServices) cachedServices = createNowlineServices();
|
|
172
|
-
return cachedServices;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async function buildDocument(source: string) {
|
|
176
|
-
const services = getServices();
|
|
177
|
-
const uri = URI.parse(`memory:///mcp-${++docCounter}.nowline`);
|
|
178
|
-
const doc = services.shared.workspace.LangiumDocumentFactory.fromString<NowlineFile>(
|
|
179
|
-
source,
|
|
180
|
-
uri,
|
|
181
|
-
);
|
|
182
|
-
await services.shared.workspace.DocumentBuilder.build([doc], { validation: true });
|
|
183
|
-
return doc;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// ---- Allowed-root enforcement -----------------------------------------------
|
|
123
|
+
// ---- Server factory ---------------------------------------------------------
|
|
187
124
|
|
|
188
125
|
function resolveAndGuard(filePath: string, allowedRoot: string): string {
|
|
189
126
|
const abs = path.resolve(allowedRoot, filePath);
|
|
@@ -229,6 +166,15 @@ function todayUtc(): Date {
|
|
|
229
166
|
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
230
167
|
}
|
|
231
168
|
|
|
169
|
+
function exampleShortName(fullName: string): string {
|
|
170
|
+
return fullName.endsWith('.nowline') ? fullName.slice(0, -'.nowline'.length) : fullName;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function findExample(name: string): ExampleFile | undefined {
|
|
174
|
+
const withExt = name.endsWith('.nowline') ? name : `${name}.nowline`;
|
|
175
|
+
return EXAMPLES.find((e) => e.name === withExt || e.name === name);
|
|
176
|
+
}
|
|
177
|
+
|
|
232
178
|
async function sourceAndPath(
|
|
233
179
|
args: { source?: string; path?: string },
|
|
234
180
|
allowedRoot: string,
|
|
@@ -257,10 +203,22 @@ export interface McpServerOptions {
|
|
|
257
203
|
|
|
258
204
|
export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
259
205
|
const allowedRoot = opts.allowedRoot ?? process.cwd();
|
|
260
|
-
const server = new McpServer(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
206
|
+
const server = new McpServer(
|
|
207
|
+
{
|
|
208
|
+
name: opts.name ?? 'nowline',
|
|
209
|
+
version: opts.version ?? '0.6.0',
|
|
210
|
+
icons: [...NOWLINE_MCP_ICONS],
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
instructions:
|
|
214
|
+
'Nowline manages roadmaps written in the .nowline plain-text DSL — NOT JSON or any other ' +
|
|
215
|
+
'structured format. All `source` parameters expect `.nowline` DSL text (starts with `nowline v1`). ' +
|
|
216
|
+
'Workflow: 1. call `reference` or `examples` to learn syntax → 2. write `.nowline` → ' +
|
|
217
|
+
'3. call `render` (validates + renders; or `validate` alone) → 4. fix errors keyed on `NL.E####` ' +
|
|
218
|
+
'and re-render → 5. review returned layout `insights` (what reflowed) → 6. when uncertain, ' +
|
|
219
|
+
'call `render` with `review:true` for a final visual check. JSON in `convert` is AST conversion only.',
|
|
220
|
+
},
|
|
221
|
+
);
|
|
264
222
|
|
|
265
223
|
// ---- Resources ----------------------------------------------------------
|
|
266
224
|
|
|
@@ -314,6 +272,25 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
314
272
|
}),
|
|
315
273
|
);
|
|
316
274
|
|
|
275
|
+
registerAppResource(
|
|
276
|
+
server,
|
|
277
|
+
'nowline-preview',
|
|
278
|
+
PREVIEW_UI_URI,
|
|
279
|
+
{
|
|
280
|
+
description:
|
|
281
|
+
'Interactive in-chat roadmap preview (MCP Apps). Hydrates via ontoolresult.',
|
|
282
|
+
},
|
|
283
|
+
async () => ({
|
|
284
|
+
contents: [
|
|
285
|
+
{
|
|
286
|
+
uri: PREVIEW_UI_URI,
|
|
287
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
288
|
+
text: PREVIEW_HTML,
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
|
|
317
294
|
// ---- Prompts ------------------------------------------------------------
|
|
318
295
|
|
|
319
296
|
registerPrompts(server);
|
|
@@ -323,8 +300,9 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
323
300
|
server.registerTool(
|
|
324
301
|
'validate',
|
|
325
302
|
{
|
|
326
|
-
description:
|
|
327
|
-
'Parse and validate a .nowline roadmap. Returns ok=true
|
|
303
|
+
description: toolDescriptionWithSyntax(
|
|
304
|
+
'Parse and validate a .nowline roadmap. Returns ok=true with optional layout insights when valid, or ok=false with structured diagnostics.',
|
|
305
|
+
),
|
|
328
306
|
inputSchema: z.object({
|
|
329
307
|
source: z.string().optional().describe('Inline .nowline source text to validate.'),
|
|
330
308
|
path: z
|
|
@@ -340,9 +318,24 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
340
318
|
const doc = await buildDocument(source);
|
|
341
319
|
const diagnostics = collectMcpDiagnostics(doc, filePath);
|
|
342
320
|
const ok = diagnostics.every((d) => d.severity !== 'error');
|
|
343
|
-
const
|
|
321
|
+
const insights = ok
|
|
322
|
+
? await collectMcpLayoutInsights({
|
|
323
|
+
source,
|
|
324
|
+
filePath,
|
|
325
|
+
today: todayUtc(),
|
|
326
|
+
locale: 'en-US',
|
|
327
|
+
readFile: createNodeHostEnv(filePath).readSource,
|
|
328
|
+
doc,
|
|
329
|
+
})
|
|
330
|
+
: [];
|
|
331
|
+
const structured = { ok, diagnostics, ...(insights.length > 0 ? { insights } : {}) };
|
|
344
332
|
return {
|
|
345
|
-
content: [
|
|
333
|
+
content: [
|
|
334
|
+
{ type: 'text', text: JSON.stringify(structured, null, 2) },
|
|
335
|
+
...(insights.length > 0
|
|
336
|
+
? [{ type: 'text' as const, text: LAYOUT_INSIGHT_HINT }]
|
|
337
|
+
: []),
|
|
338
|
+
],
|
|
346
339
|
structuredContent: structured,
|
|
347
340
|
};
|
|
348
341
|
},
|
|
@@ -378,8 +371,9 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
378
371
|
server.registerTool(
|
|
379
372
|
'create',
|
|
380
373
|
{
|
|
381
|
-
description:
|
|
374
|
+
description: toolDescriptionWithSyntax(
|
|
382
375
|
'Write a new .nowline file after validation. Overwrites if the path already exists.',
|
|
376
|
+
),
|
|
383
377
|
inputSchema: z.object({
|
|
384
378
|
path: z.string().describe('Absolute or relative path to write the .nowline file.'),
|
|
385
379
|
source: z.string().describe('The .nowline source text to write.'),
|
|
@@ -390,20 +384,8 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
390
384
|
},
|
|
391
385
|
async (args) => {
|
|
392
386
|
const abs = resolveAndGuard(args.path, allowedRoot);
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
const errors = diagnostics.filter((d) => d.severity === 'error');
|
|
396
|
-
if (errors.length > 0) {
|
|
397
|
-
return {
|
|
398
|
-
content: [
|
|
399
|
-
{
|
|
400
|
-
type: 'text',
|
|
401
|
-
text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
|
|
402
|
-
},
|
|
403
|
-
],
|
|
404
|
-
isError: true,
|
|
405
|
-
};
|
|
406
|
-
}
|
|
387
|
+
const blocked = await diagnosticsErrorBlock(args.source, abs);
|
|
388
|
+
if (!blocked.ok) return blocked.response;
|
|
407
389
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
408
390
|
await fs.writeFile(abs, args.source, 'utf-8');
|
|
409
391
|
const structured = { ok: true, path: abs };
|
|
@@ -419,7 +401,9 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
419
401
|
server.registerTool(
|
|
420
402
|
'update',
|
|
421
403
|
{
|
|
422
|
-
description:
|
|
404
|
+
description: toolDescriptionWithSyntax(
|
|
405
|
+
'Replace an existing .nowline file after validation.',
|
|
406
|
+
),
|
|
423
407
|
inputSchema: z.object({
|
|
424
408
|
path: z
|
|
425
409
|
.string()
|
|
@@ -431,20 +415,8 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
431
415
|
},
|
|
432
416
|
async (args) => {
|
|
433
417
|
const abs = resolveAndGuard(args.path, allowedRoot);
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
const errors = diagnostics.filter((d) => d.severity === 'error');
|
|
437
|
-
if (errors.length > 0) {
|
|
438
|
-
return {
|
|
439
|
-
content: [
|
|
440
|
-
{
|
|
441
|
-
type: 'text',
|
|
442
|
-
text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
|
|
443
|
-
},
|
|
444
|
-
],
|
|
445
|
-
isError: true,
|
|
446
|
-
};
|
|
447
|
-
}
|
|
418
|
+
const blocked = await diagnosticsErrorBlock(args.source, abs);
|
|
419
|
+
if (!blocked.ok) return blocked.response;
|
|
448
420
|
await fs.writeFile(abs, args.source, 'utf-8');
|
|
449
421
|
const structured = { ok: true, path: abs };
|
|
450
422
|
return {
|
|
@@ -514,14 +486,24 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
514
486
|
|
|
515
487
|
// ---- render -------------------------------------------------------------
|
|
516
488
|
|
|
517
|
-
|
|
489
|
+
registerAppTool(
|
|
490
|
+
server,
|
|
518
491
|
'render',
|
|
519
492
|
{
|
|
520
|
-
description:
|
|
521
|
-
'
|
|
493
|
+
description: toolDescriptionWithSyntax(
|
|
494
|
+
'Validate then render a .nowline roadmap to SVG or PNG (combined validate+render+share). ' +
|
|
495
|
+
'Returns structured diagnostics on error-severity input instead of a raw kernel error.',
|
|
496
|
+
),
|
|
522
497
|
inputSchema: z.object({
|
|
523
498
|
source: z.string().optional().describe('Inline .nowline source text.'),
|
|
524
|
-
path: z
|
|
499
|
+
path: z
|
|
500
|
+
.string()
|
|
501
|
+
.optional()
|
|
502
|
+
.describe(
|
|
503
|
+
'Real local filesystem path to the .nowline file (e.g. /Users/name/Desktop/foo.nowline). ' +
|
|
504
|
+
'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
|
|
505
|
+
'those do not exist on the host filesystem. Pass `source` instead.',
|
|
506
|
+
),
|
|
525
507
|
format: z
|
|
526
508
|
.enum(['svg', 'png'])
|
|
527
509
|
.optional()
|
|
@@ -542,25 +524,42 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
542
524
|
output: z
|
|
543
525
|
.string()
|
|
544
526
|
.optional()
|
|
545
|
-
.describe(
|
|
527
|
+
.describe(
|
|
528
|
+
'Real local filesystem path to write the output file (e.g. /Users/name/Desktop/roadmap.svg). ' +
|
|
529
|
+
'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
|
|
530
|
+
'omit this parameter to receive output inline instead.',
|
|
531
|
+
),
|
|
546
532
|
share: z
|
|
547
533
|
.boolean()
|
|
548
534
|
.optional()
|
|
549
535
|
.describe(
|
|
550
536
|
'When true, include a shareUrl pointing to https://free.nowline.io/open.',
|
|
551
537
|
),
|
|
538
|
+
review: z
|
|
539
|
+
.boolean()
|
|
540
|
+
.optional()
|
|
541
|
+
.describe(
|
|
542
|
+
'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.',
|
|
543
|
+
),
|
|
552
544
|
preview: z
|
|
553
545
|
.boolean()
|
|
554
546
|
.optional()
|
|
555
547
|
.describe(
|
|
556
|
-
'When true,
|
|
548
|
+
'When true, force the in-chat MCP Apps preview. On MCP Apps hosts the preview auto-renders via _meta.ui without this flag.',
|
|
557
549
|
),
|
|
558
550
|
}),
|
|
559
551
|
outputSchema: RenderOutputSchema,
|
|
560
552
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
553
|
+
_meta: {
|
|
554
|
+
ui: { resourceUri: PREVIEW_UI_URI },
|
|
555
|
+
'openai/outputTemplate': PREVIEW_UI_URI,
|
|
556
|
+
},
|
|
561
557
|
},
|
|
562
558
|
async (args) => {
|
|
563
559
|
const { source, filePath } = await sourceAndPath(args, allowedRoot);
|
|
560
|
+
const blocked = await diagnosticsErrorBlock(source, filePath);
|
|
561
|
+
if (!blocked.ok) return blocked.response;
|
|
562
|
+
|
|
564
563
|
const format: ExportFormat = args.format ?? 'svg';
|
|
565
564
|
const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
|
|
566
565
|
const inputs: RenderInputs = {
|
|
@@ -571,48 +570,90 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
571
570
|
width: args.width,
|
|
572
571
|
pngScale: args.scale,
|
|
573
572
|
};
|
|
574
|
-
|
|
573
|
+
const host = createNodeHostEnv(filePath);
|
|
574
|
+
const appActive = args.preview === true || clientSupportsAppsUi(server);
|
|
575
|
+
// Skip the full render when the bytes won't be used: apps host with
|
|
576
|
+
// no write-to-disk path and no review attachment requested.
|
|
577
|
+
const needsRender = !appActive || !!args.output || args.review === true;
|
|
578
|
+
if (needsRender && (format === 'png' || args.review === true)) {
|
|
575
579
|
const result = await resolveFonts({ headless: true });
|
|
576
580
|
inputs.fonts = { sans: result.sans, mono: result.mono };
|
|
577
581
|
}
|
|
578
|
-
const
|
|
579
|
-
|
|
582
|
+
const bytes = needsRender
|
|
583
|
+
? await exportDocument(source, format, inputs, host)
|
|
584
|
+
: new Uint8Array(0);
|
|
580
585
|
const shareUrl = args.share
|
|
581
586
|
? (buildShareLink({ source, share: true }) ?? undefined)
|
|
582
587
|
: undefined;
|
|
583
588
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
:
|
|
589
|
+
const insights = await collectMcpLayoutInsights({
|
|
590
|
+
source,
|
|
591
|
+
filePath,
|
|
592
|
+
today,
|
|
593
|
+
theme: args.theme ?? 'light',
|
|
594
|
+
width: args.width,
|
|
595
|
+
locale: 'en-US',
|
|
596
|
+
readFile: host.readSource,
|
|
597
|
+
doc: blocked.doc,
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
const previewPayload: PreviewPayload = {
|
|
601
|
+
source,
|
|
602
|
+
theme: args.theme,
|
|
603
|
+
now: args.now,
|
|
604
|
+
width: args.width,
|
|
605
|
+
locale: 'en-US',
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const reviewBlocks =
|
|
609
|
+
args.review === true
|
|
610
|
+
? await buildReviewContentBlocks(source, format, bytes, inputs, host)
|
|
611
|
+
: [];
|
|
612
|
+
|
|
613
|
+
const insightHintBlocks =
|
|
614
|
+
insights.length > 0 ? [{ type: 'text' as const, text: LAYOUT_INSIGHT_HINT }] : [];
|
|
615
|
+
const insightsField = insights.length > 0 ? { insights } : {};
|
|
599
616
|
|
|
600
617
|
if (args.output) {
|
|
601
618
|
const outAbs = resolveAndGuard(args.output, allowedRoot);
|
|
602
619
|
await fs.mkdir(path.dirname(outAbs), { recursive: true });
|
|
603
620
|
await fs.writeFile(outAbs, bytes);
|
|
604
|
-
const structured = {
|
|
621
|
+
const structured = {
|
|
622
|
+
format,
|
|
623
|
+
path: outAbs,
|
|
624
|
+
bytes: bytes.byteLength,
|
|
625
|
+
shareUrl,
|
|
626
|
+
...insightsField,
|
|
627
|
+
};
|
|
628
|
+
return {
|
|
629
|
+
content: [
|
|
630
|
+
...(appActive ? [leanPreviewBlock(previewPayload)] : []),
|
|
631
|
+
{ type: 'text' as const, text: JSON.stringify(structured, null, 2) },
|
|
632
|
+
...insightHintBlocks,
|
|
633
|
+
...reviewBlocks,
|
|
634
|
+
],
|
|
635
|
+
structuredContent: structured,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (appActive) {
|
|
640
|
+
const structured = {
|
|
641
|
+
format,
|
|
642
|
+
shareUrl,
|
|
643
|
+
...insightsField,
|
|
644
|
+
};
|
|
605
645
|
return {
|
|
606
646
|
content: [
|
|
607
|
-
|
|
608
|
-
...
|
|
647
|
+
leanPreviewBlock(previewPayload),
|
|
648
|
+
...insightHintBlocks,
|
|
649
|
+
...reviewBlocks,
|
|
609
650
|
],
|
|
610
651
|
structuredContent: structured,
|
|
611
652
|
};
|
|
612
653
|
}
|
|
613
654
|
|
|
614
655
|
if (format === 'png') {
|
|
615
|
-
const structured = { format, bytes: bytes.byteLength, shareUrl };
|
|
656
|
+
const structured = { format, bytes: bytes.byteLength, shareUrl, ...insightsField };
|
|
616
657
|
return {
|
|
617
658
|
content: [
|
|
618
659
|
{
|
|
@@ -620,17 +661,19 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
620
661
|
data: Buffer.from(bytes).toString('base64'),
|
|
621
662
|
mimeType: 'image/png',
|
|
622
663
|
},
|
|
623
|
-
...
|
|
664
|
+
...insightHintBlocks,
|
|
665
|
+
...reviewBlocks,
|
|
624
666
|
],
|
|
625
667
|
structuredContent: structured,
|
|
626
668
|
};
|
|
627
669
|
}
|
|
628
670
|
const svgText = new TextDecoder('utf-8').decode(bytes);
|
|
629
|
-
const structured = { format, shareUrl };
|
|
671
|
+
const structured = { format, shareUrl, ...insightsField };
|
|
630
672
|
return {
|
|
631
673
|
content: [
|
|
632
674
|
{ type: 'text', text: svgText, mimeType: 'image/svg+xml' },
|
|
633
|
-
...
|
|
675
|
+
...insightHintBlocks,
|
|
676
|
+
...reviewBlocks,
|
|
634
677
|
],
|
|
635
678
|
structuredContent: structured,
|
|
636
679
|
};
|
|
@@ -649,14 +692,26 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
649
692
|
'Export a .nowline roadmap to any of the eight canonical formats. Byte-identical to `nowline -f <format>` for the same source and inputs.',
|
|
650
693
|
inputSchema: z.object({
|
|
651
694
|
source: z.string().optional().describe('Inline .nowline source text.'),
|
|
652
|
-
path: z
|
|
695
|
+
path: z
|
|
696
|
+
.string()
|
|
697
|
+
.optional()
|
|
698
|
+
.describe(
|
|
699
|
+
'Real local filesystem path to the .nowline file (e.g. /Users/name/Desktop/foo.nowline). ' +
|
|
700
|
+
'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
|
|
701
|
+
'those do not exist on the host filesystem. Pass `source` instead.',
|
|
702
|
+
),
|
|
653
703
|
format: z
|
|
654
704
|
.enum(EXPORT_FORMATS)
|
|
655
705
|
.describe('Export format: pdf, html, mermaid, xlsx, msproj, or png.'),
|
|
656
706
|
output: z
|
|
657
707
|
.string()
|
|
658
708
|
.optional()
|
|
659
|
-
.describe(
|
|
709
|
+
.describe(
|
|
710
|
+
'Real local filesystem path to write the output (e.g. /Users/name/Desktop/roadmap.pdf). ' +
|
|
711
|
+
'Required for binary formats (pdf, xlsx, msproj, png). ' +
|
|
712
|
+
'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
|
|
713
|
+
'those do not exist on the host filesystem.',
|
|
714
|
+
),
|
|
660
715
|
now: z.string().optional().describe('Now-line date as YYYY-MM-DD (UTC).'),
|
|
661
716
|
theme: z.enum(['light', 'dark', 'grayscale']).optional(),
|
|
662
717
|
scale: z.number().optional().describe('PNG scale factor.'),
|
|
@@ -682,6 +737,9 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
682
737
|
},
|
|
683
738
|
async (args) => {
|
|
684
739
|
const { source, filePath } = await sourceAndPath(args, allowedRoot);
|
|
740
|
+
const blocked = await diagnosticsErrorBlock(source, filePath);
|
|
741
|
+
if (!blocked.ok) return blocked.response;
|
|
742
|
+
|
|
685
743
|
const format: ExportFormat = args.format as NonRenderFormat;
|
|
686
744
|
const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
|
|
687
745
|
const inputs: RenderInputs = {
|
|
@@ -755,7 +813,14 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
755
813
|
'Convert between .nowline source text and its JSON AST representation. `to:json` serializes a .nowline file to the JSON AST; `to:nowline` pretty-prints a JSON AST back to canonical .nowline source.',
|
|
756
814
|
inputSchema: z.object({
|
|
757
815
|
source: z.string().optional().describe('Inline source text to convert.'),
|
|
758
|
-
path: z
|
|
816
|
+
path: z
|
|
817
|
+
.string()
|
|
818
|
+
.optional()
|
|
819
|
+
.describe(
|
|
820
|
+
'Real local filesystem path to the source file. ' +
|
|
821
|
+
'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
|
|
822
|
+
'pass `source` instead.',
|
|
823
|
+
),
|
|
759
824
|
to: z
|
|
760
825
|
.enum(['json', 'nowline'])
|
|
761
826
|
.describe(
|
|
@@ -930,11 +995,165 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
930
995
|
},
|
|
931
996
|
);
|
|
932
997
|
|
|
998
|
+
// ---- reference / examples / schema (discovery tools) --------------------
|
|
999
|
+
|
|
1000
|
+
server.registerTool(
|
|
1001
|
+
'reference',
|
|
1002
|
+
{
|
|
1003
|
+
description:
|
|
1004
|
+
'Return the Nowline DSL reference (condensed cheatsheet or full man page). Callable alternative to the nowline://reference resource.',
|
|
1005
|
+
inputSchema: z.object({
|
|
1006
|
+
format: z
|
|
1007
|
+
.enum(['condensed', 'full'])
|
|
1008
|
+
.optional()
|
|
1009
|
+
.describe('Reference format. Defaults to condensed.'),
|
|
1010
|
+
}),
|
|
1011
|
+
outputSchema: ReferenceOutputSchema,
|
|
1012
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
1013
|
+
},
|
|
1014
|
+
async (args) => {
|
|
1015
|
+
const format = args.format ?? 'condensed';
|
|
1016
|
+
const text = format === 'full' ? REFERENCE_MAN_PAGE : REFERENCE_CHEATSHEET;
|
|
1017
|
+
const structured = { format, text };
|
|
1018
|
+
return {
|
|
1019
|
+
content: [{ type: 'text', text }],
|
|
1020
|
+
structuredContent: structured,
|
|
1021
|
+
};
|
|
1022
|
+
},
|
|
1023
|
+
);
|
|
1024
|
+
|
|
1025
|
+
server.registerTool(
|
|
1026
|
+
'examples',
|
|
1027
|
+
{
|
|
1028
|
+
description:
|
|
1029
|
+
'Return canonical .nowline example sources. Callable alternative to the nowline://examples resource.',
|
|
1030
|
+
inputSchema: z.object({
|
|
1031
|
+
name: z
|
|
1032
|
+
.string()
|
|
1033
|
+
.optional()
|
|
1034
|
+
.describe('Example name. Omit for the catalog plus minimal inline.'),
|
|
1035
|
+
}),
|
|
1036
|
+
outputSchema: ExamplesOutputSchema,
|
|
1037
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
1038
|
+
},
|
|
1039
|
+
async (args) => {
|
|
1040
|
+
const exampleNames = EXAMPLES.map((e) => exampleShortName(e.name));
|
|
1041
|
+
if (args.name) {
|
|
1042
|
+
const ex = findExample(args.name);
|
|
1043
|
+
if (!ex) {
|
|
1044
|
+
return {
|
|
1045
|
+
content: [
|
|
1046
|
+
{
|
|
1047
|
+
type: 'text',
|
|
1048
|
+
text: JSON.stringify({
|
|
1049
|
+
error: `Unknown example "${args.name}".`,
|
|
1050
|
+
names: exampleNames,
|
|
1051
|
+
}),
|
|
1052
|
+
},
|
|
1053
|
+
],
|
|
1054
|
+
isError: true,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
const structured = { name: exampleShortName(ex.name), source: ex.content };
|
|
1058
|
+
return {
|
|
1059
|
+
content: [{ type: 'text', text: ex.content }],
|
|
1060
|
+
structuredContent: structured,
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
const minimal = findExample('minimal') ?? EXAMPLES[0];
|
|
1064
|
+
const structured = {
|
|
1065
|
+
names: exampleNames,
|
|
1066
|
+
name: exampleShortName(minimal.name),
|
|
1067
|
+
source: minimal.content,
|
|
1068
|
+
};
|
|
1069
|
+
return {
|
|
1070
|
+
content: [
|
|
1071
|
+
{
|
|
1072
|
+
type: 'text',
|
|
1073
|
+
text: `# Examples\n\n${exampleNames.map((n) => `- ${n}`).join('\n')}\n\n## ${structured.name}\n\n${minimal.content}`,
|
|
1074
|
+
},
|
|
1075
|
+
],
|
|
1076
|
+
structuredContent: structured,
|
|
1077
|
+
};
|
|
1078
|
+
},
|
|
1079
|
+
);
|
|
1080
|
+
|
|
1081
|
+
server.registerTool(
|
|
1082
|
+
'schema',
|
|
1083
|
+
{
|
|
1084
|
+
description:
|
|
1085
|
+
'Return the structured Nowline DSL key vocabulary (directive keys, entity types, item properties).',
|
|
1086
|
+
inputSchema: z.object({}),
|
|
1087
|
+
outputSchema: SchemaOutputSchema,
|
|
1088
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
1089
|
+
},
|
|
1090
|
+
async () => {
|
|
1091
|
+
const structured = {
|
|
1092
|
+
directiveKeys: [...SCHEMA_VOCABULARY.directiveKeys],
|
|
1093
|
+
entityTypes: [...SCHEMA_VOCABULARY.entityTypes],
|
|
1094
|
+
itemPropertyKeys: [...SCHEMA_VOCABULARY.itemPropertyKeys],
|
|
1095
|
+
};
|
|
1096
|
+
return {
|
|
1097
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
1098
|
+
structuredContent: structured,
|
|
1099
|
+
};
|
|
1100
|
+
},
|
|
1101
|
+
);
|
|
1102
|
+
|
|
933
1103
|
return server;
|
|
934
1104
|
}
|
|
935
1105
|
|
|
936
1106
|
// ---- Helpers ----------------------------------------------------------------
|
|
937
1107
|
|
|
1108
|
+
type ContentBlock =
|
|
1109
|
+
| { type: 'text'; text: string; mimeType?: string }
|
|
1110
|
+
| { type: 'image'; data: string; mimeType: string };
|
|
1111
|
+
|
|
1112
|
+
async function buildReviewContentBlocks(
|
|
1113
|
+
source: string,
|
|
1114
|
+
format: ExportFormat,
|
|
1115
|
+
artifactBytes: Uint8Array,
|
|
1116
|
+
inputs: RenderInputs,
|
|
1117
|
+
host: HostEnv,
|
|
1118
|
+
): Promise<ContentBlock[]> {
|
|
1119
|
+
const blocks: ContentBlock[] = [
|
|
1120
|
+
{
|
|
1121
|
+
type: 'text',
|
|
1122
|
+
text:
|
|
1123
|
+
'Review this raster for layout issues (truncated labels, crowded lanes, ' +
|
|
1124
|
+
'now-line position) before finalizing.',
|
|
1125
|
+
},
|
|
1126
|
+
];
|
|
1127
|
+
|
|
1128
|
+
const artifactWidth = inputs.width ?? DEFAULT_RENDER_WIDTH;
|
|
1129
|
+
let inspectionBytes: Uint8Array;
|
|
1130
|
+
|
|
1131
|
+
if (format === 'png' && artifactWidth <= REVIEW_MAX_WIDTH) {
|
|
1132
|
+
inspectionBytes = artifactBytes;
|
|
1133
|
+
} else {
|
|
1134
|
+
const reviewInputs: RenderInputs = {
|
|
1135
|
+
...inputs,
|
|
1136
|
+
width: Math.min(artifactWidth, REVIEW_MAX_WIDTH),
|
|
1137
|
+
pngScale: 1,
|
|
1138
|
+
};
|
|
1139
|
+
if (!reviewInputs.fonts) {
|
|
1140
|
+
const fonts = await resolveFonts({ headless: true });
|
|
1141
|
+
reviewInputs.fonts = { sans: fonts.sans, mono: fonts.mono };
|
|
1142
|
+
}
|
|
1143
|
+
inspectionBytes = await exportDocument(source, 'png', reviewInputs, host);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (format !== 'png' || inspectionBytes.byteLength !== artifactBytes.byteLength) {
|
|
1147
|
+
blocks.push({
|
|
1148
|
+
type: 'image',
|
|
1149
|
+
data: Buffer.from(inspectionBytes).toString('base64'),
|
|
1150
|
+
mimeType: 'image/png',
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return blocks;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
938
1157
|
async function listNowlineFiles(dir: string, recursive: boolean): Promise<string[]> {
|
|
939
1158
|
const results: string[] = [];
|
|
940
1159
|
try {
|