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