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