@nowline/mcp 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/capabilities.d.ts +9 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +17 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/diagnostics.d.ts +59 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +117 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/generated/resources.d.ts +2 -0
- package/dist/generated/resources.d.ts.map +1 -1
- package/dist/generated/resources.js +6 -1
- package/dist/generated/resources.js.map +1 -1
- package/dist/generated/ui-bundle.d.ts +5 -0
- package/dist/generated/ui-bundle.d.ts.map +1 -0
- package/dist/generated/ui-bundle.js +11 -0
- package/dist/generated/ui-bundle.js.map +1 -0
- package/dist/index.js +63 -12
- package/dist/index.js.map +1 -1
- package/dist/prompts.d.ts +3 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +143 -0
- package/dist/prompts.js.map +1 -0
- 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 +148 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +88 -0
- package/dist/schemas.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +531 -121
- package/dist/server.js.map +1 -1
- package/dist/ui/entry.d.ts +2 -0
- package/dist/ui/entry.d.ts.map +1 -0
- package/dist/ui/entry.js +187 -0
- package/dist/ui/entry.js.map +1 -0
- 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 +15 -6
- package/src/branding.ts +26 -0
- package/src/capabilities.ts +25 -0
- package/src/diagnostics.ts +185 -0
- package/src/generated/resources.ts +7 -1
- package/src/generated/ui-bundle.ts +12 -0
- package/src/index.ts +75 -13
- package/src/prompts.ts +172 -0
- package/src/reference-cheatsheet.ts +47 -0
- package/src/schema-vocab.ts +55 -0
- package/src/schemas.ts +106 -0
- package/src/server.ts +725 -139
- package/src/ui/entry.ts +214 -0
- package/src/ui/payload.ts +63 -0
package/src/server.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
// @nowline/mcp server factory.
|
|
2
2
|
//
|
|
3
|
-
// Creates and configures an McpServer instance with all
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// paths expose an identical surface.
|
|
3
|
+
// Creates and configures an McpServer instance with all tools, resources, and
|
|
4
|
+
// prompts. The factory is shared by the entry-point bin (npx @nowline/mcp)
|
|
5
|
+
// and the CLI's --mcp flag so both paths expose an identical surface.
|
|
7
6
|
//
|
|
8
|
-
// Spec: specs/mcp.md.
|
|
7
|
+
// Spec: specs/mcp.md.
|
|
9
8
|
|
|
10
9
|
import { promises as fs } from 'node:fs';
|
|
11
10
|
import * as path from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
getUiCapability,
|
|
13
|
+
RESOURCE_MIME_TYPE,
|
|
14
|
+
registerAppResource,
|
|
15
|
+
registerAppTool,
|
|
16
|
+
} from '@modelcontextprotocol/ext-apps/server';
|
|
12
17
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
|
-
import {
|
|
18
|
+
import { parseNowlineJson, printNowlineFile } from '@nowline/core';
|
|
14
19
|
import {
|
|
15
20
|
type ExportFormat,
|
|
16
21
|
exportDocument,
|
|
@@ -18,75 +23,104 @@ import {
|
|
|
18
23
|
type RenderInputs,
|
|
19
24
|
} from '@nowline/export';
|
|
20
25
|
import { resolveFonts } from '@nowline/export-core';
|
|
21
|
-
import {
|
|
26
|
+
import { buildShareLink } from '@nowline/share-link';
|
|
22
27
|
import { z } from 'zod';
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
import { NOWLINE_MCP_ICONS } from './branding.js';
|
|
29
|
+
import { CAPABILITIES } from './capabilities.js';
|
|
30
|
+
import {
|
|
31
|
+
buildDocument,
|
|
32
|
+
collectMcpDiagnostics,
|
|
33
|
+
collectMcpLayoutInsights,
|
|
34
|
+
DEFAULT_RENDER_WIDTH,
|
|
35
|
+
diagnosticsErrorBlock,
|
|
36
|
+
LAYOUT_INSIGHT_HINT,
|
|
37
|
+
REVIEW_MAX_WIDTH,
|
|
38
|
+
toolDescriptionWithSyntax,
|
|
39
|
+
} from './diagnostics.js';
|
|
40
|
+
import {
|
|
41
|
+
CONVERSIONS_GUIDE,
|
|
42
|
+
EXAMPLES,
|
|
43
|
+
type ExampleFile,
|
|
44
|
+
REFERENCE_MAN_PAGE,
|
|
45
|
+
} from './generated/resources.js';
|
|
46
|
+
import { PREVIEW_HTML } from './generated/ui-bundle.js';
|
|
47
|
+
import { registerPrompts } from './prompts.js';
|
|
48
|
+
import { REFERENCE_CHEATSHEET } from './reference-cheatsheet.js';
|
|
49
|
+
import { SCHEMA_VOCABULARY } from './schema-vocab.js';
|
|
50
|
+
import {
|
|
51
|
+
CapabilitiesOutputSchema,
|
|
52
|
+
ConvertOutputSchema,
|
|
53
|
+
CreateOutputSchema,
|
|
54
|
+
DeleteOutputSchema,
|
|
55
|
+
ExamplesOutputSchema,
|
|
56
|
+
ExportOutputSchema,
|
|
57
|
+
ListItemsOutputSchema,
|
|
58
|
+
ListOutputSchema,
|
|
59
|
+
ReadOutputSchema,
|
|
60
|
+
ReferenceOutputSchema,
|
|
61
|
+
RenderOutputSchema,
|
|
62
|
+
SchemaOutputSchema,
|
|
63
|
+
UpdateOutputSchema,
|
|
64
|
+
ValidateOutputSchema,
|
|
65
|
+
} from './schemas.js';
|
|
26
66
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
67
|
+
// ---- MCP Apps UI (in-chat live preview) -------------------------------------
|
|
68
|
+
//
|
|
69
|
+
// Official MCP Apps model (SEP-1865): a pre-declared ui:// resource serves
|
|
70
|
+
// static HTML; the render tool declares _meta.ui.resourceUri; per-call data
|
|
71
|
+
// flows through the ontoolresult handshake. When an MCP Apps host is active,
|
|
72
|
+
// render returns a lean nowline.preview JSON payload (no inline SVG/PNG) so
|
|
73
|
+
// results stay under the host's ~150K inline cap. Non-apps hosts still get
|
|
74
|
+
// the full SVG/PNG inline (graceful degradation).
|
|
35
75
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const raw = collectDocumentDiagnostics(doc);
|
|
41
|
-
const out: McpDiagnostic[] = [];
|
|
42
|
-
for (const d of raw) {
|
|
43
|
-
if (d.origin === 'lexer' || d.origin === 'parser') {
|
|
44
|
-
out.push({
|
|
45
|
-
file: filePath,
|
|
46
|
-
line: 1,
|
|
47
|
-
column: 1,
|
|
48
|
-
severity: 'error',
|
|
49
|
-
code: d.origin === 'lexer' ? 'lexing-error' : 'parsing-error',
|
|
50
|
-
message: d.error.message,
|
|
51
|
-
});
|
|
52
|
-
} else {
|
|
53
|
-
const diag = d.diagnostic;
|
|
54
|
-
const range = diag.range;
|
|
55
|
-
out.push({
|
|
56
|
-
file: filePath,
|
|
57
|
-
line: (range?.start.line ?? 0) + 1,
|
|
58
|
-
column: (range?.start.character ?? 0) + 1,
|
|
59
|
-
severity: diag.severity === 1 ? 'error' : 'warning',
|
|
60
|
-
code: String(diag.code ?? 'unknown'),
|
|
61
|
-
message: diag.message,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return out;
|
|
66
|
-
}
|
|
76
|
+
/** SEP-1865 UI extension capability id; also probed under common short keys. */
|
|
77
|
+
const MCP_APPS_UI_CAPABILITY = 'io.modelcontextprotocol/ui';
|
|
78
|
+
/** Versioned URI doubles as a cache key — bump suffix on bundle changes. */
|
|
79
|
+
export const PREVIEW_UI_URI = 'ui://nowline/preview-v1';
|
|
67
80
|
|
|
68
|
-
|
|
81
|
+
function clientSupportsAppsUi(server: McpServer): boolean {
|
|
82
|
+
// SEP-1724 negotiated extensions: canonical id under `extensions`.
|
|
83
|
+
const caps = server.server.getClientCapabilities() as
|
|
84
|
+
| {
|
|
85
|
+
experimental?: Record<string, unknown>;
|
|
86
|
+
extensions?: Record<string, unknown>;
|
|
87
|
+
}
|
|
88
|
+
| undefined;
|
|
89
|
+
if (!caps) return false;
|
|
90
|
+
if (getUiCapability(caps as Parameters<typeof getUiCapability>[0])) return true;
|
|
69
91
|
|
|
70
|
-
|
|
71
|
-
|
|
92
|
+
// Hosts also advertise under `experimental` or short `ui` / `apps` aliases.
|
|
93
|
+
const buckets = [caps.extensions, caps.experimental].filter(
|
|
94
|
+
(bucket): bucket is Record<string, unknown> => Boolean(bucket),
|
|
95
|
+
);
|
|
96
|
+
return buckets.some((bucket) =>
|
|
97
|
+
Boolean(bucket[MCP_APPS_UI_CAPABILITY] || bucket.ui || bucket.apps),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
72
100
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
101
|
+
interface PreviewPayload {
|
|
102
|
+
source: string;
|
|
103
|
+
theme?: string;
|
|
104
|
+
now?: string;
|
|
105
|
+
width?: number;
|
|
106
|
+
locale?: string;
|
|
76
107
|
}
|
|
77
108
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
109
|
+
function leanPreviewBlock(payload: PreviewPayload) {
|
|
110
|
+
return {
|
|
111
|
+
type: 'text' as const,
|
|
112
|
+
text: JSON.stringify({
|
|
113
|
+
kind: 'nowline.preview',
|
|
114
|
+
source: payload.source,
|
|
115
|
+
theme: payload.theme,
|
|
116
|
+
now: payload.now,
|
|
117
|
+
width: payload.width,
|
|
118
|
+
locale: payload.locale,
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
87
121
|
}
|
|
88
122
|
|
|
89
|
-
// ----
|
|
123
|
+
// ---- Server factory ---------------------------------------------------------
|
|
90
124
|
|
|
91
125
|
function resolveAndGuard(filePath: string, allowedRoot: string): string {
|
|
92
126
|
const abs = path.resolve(allowedRoot, filePath);
|
|
@@ -114,7 +148,6 @@ function createNodeHostEnv(sourcePath: string): HostEnv {
|
|
|
114
148
|
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
115
149
|
},
|
|
116
150
|
async loadWasm(): Promise<ArrayBuffer> {
|
|
117
|
-
// Resolve resvg.wasm relative to @nowline/export-png/dist/ at runtime.
|
|
118
151
|
const { createRequire } = await import('node:module');
|
|
119
152
|
const req = createRequire(import.meta.url);
|
|
120
153
|
const entry = req.resolve('@resvg/resvg-wasm');
|
|
@@ -133,6 +166,15 @@ function todayUtc(): Date {
|
|
|
133
166
|
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
134
167
|
}
|
|
135
168
|
|
|
169
|
+
function exampleShortName(fullName: string): string {
|
|
170
|
+
return fullName.endsWith('.nowline') ? fullName.slice(0, -'.nowline'.length) : fullName;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function findExample(name: string): ExampleFile | undefined {
|
|
174
|
+
const withExt = name.endsWith('.nowline') ? name : `${name}.nowline`;
|
|
175
|
+
return EXAMPLES.find((e) => e.name === withExt || e.name === name);
|
|
176
|
+
}
|
|
177
|
+
|
|
136
178
|
async function sourceAndPath(
|
|
137
179
|
args: { source?: string; path?: string },
|
|
138
180
|
allowedRoot: string,
|
|
@@ -161,10 +203,22 @@ export interface McpServerOptions {
|
|
|
161
203
|
|
|
162
204
|
export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
163
205
|
const allowedRoot = opts.allowedRoot ?? process.cwd();
|
|
164
|
-
const server = new McpServer(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
206
|
+
const server = new McpServer(
|
|
207
|
+
{
|
|
208
|
+
name: opts.name ?? 'nowline',
|
|
209
|
+
version: opts.version ?? '0.6.0',
|
|
210
|
+
icons: [...NOWLINE_MCP_ICONS],
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
instructions:
|
|
214
|
+
'Nowline manages roadmaps written in the .nowline plain-text DSL — NOT JSON or any other ' +
|
|
215
|
+
'structured format. All `source` parameters expect `.nowline` DSL text (starts with `nowline v1`). ' +
|
|
216
|
+
'Workflow: 1. call `reference` or `examples` to learn syntax → 2. write `.nowline` → ' +
|
|
217
|
+
'3. call `render` (validates + renders; or `validate` alone) → 4. fix errors keyed on `NL.E####` ' +
|
|
218
|
+
'and re-render → 5. review returned layout `insights` (what reflowed) → 6. when uncertain, ' +
|
|
219
|
+
'call `render` with `review:true` for a final visual check. JSON in `convert` is AST conversion only.',
|
|
220
|
+
},
|
|
221
|
+
);
|
|
168
222
|
|
|
169
223
|
// ---- Resources ----------------------------------------------------------
|
|
170
224
|
|
|
@@ -199,13 +253,56 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
199
253
|
}),
|
|
200
254
|
);
|
|
201
255
|
|
|
256
|
+
server.registerResource(
|
|
257
|
+
'nowline-conversions',
|
|
258
|
+
'nowline://conversions',
|
|
259
|
+
{
|
|
260
|
+
description:
|
|
261
|
+
'LLM-mediated conversion guide: how to translate Mermaid gantt, MS Project, Excel, Google Sheets timeline, and generic CSV into Nowline DSL.',
|
|
262
|
+
mimeType: 'text/plain',
|
|
263
|
+
},
|
|
264
|
+
async () => ({
|
|
265
|
+
contents: [
|
|
266
|
+
{
|
|
267
|
+
uri: 'nowline://conversions',
|
|
268
|
+
text: CONVERSIONS_GUIDE,
|
|
269
|
+
mimeType: 'text/plain',
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
registerAppResource(
|
|
276
|
+
server,
|
|
277
|
+
'nowline-preview',
|
|
278
|
+
PREVIEW_UI_URI,
|
|
279
|
+
{
|
|
280
|
+
description:
|
|
281
|
+
'Interactive in-chat roadmap preview (MCP Apps). Hydrates via ontoolresult.',
|
|
282
|
+
},
|
|
283
|
+
async () => ({
|
|
284
|
+
contents: [
|
|
285
|
+
{
|
|
286
|
+
uri: PREVIEW_UI_URI,
|
|
287
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
288
|
+
text: PREVIEW_HTML,
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
// ---- Prompts ------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
registerPrompts(server);
|
|
297
|
+
|
|
202
298
|
// ---- validate -----------------------------------------------------------
|
|
203
299
|
|
|
204
300
|
server.registerTool(
|
|
205
301
|
'validate',
|
|
206
302
|
{
|
|
207
|
-
description:
|
|
208
|
-
'Parse and validate a .nowline roadmap. Returns ok=true
|
|
303
|
+
description: toolDescriptionWithSyntax(
|
|
304
|
+
'Parse and validate a .nowline roadmap. Returns ok=true with optional layout insights when valid, or ok=false with structured diagnostics.',
|
|
305
|
+
),
|
|
209
306
|
inputSchema: z.object({
|
|
210
307
|
source: z.string().optional().describe('Inline .nowline source text to validate.'),
|
|
211
308
|
path: z
|
|
@@ -213,14 +310,33 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
213
310
|
.optional()
|
|
214
311
|
.describe('Absolute or relative path to a .nowline file to validate.'),
|
|
215
312
|
}),
|
|
313
|
+
outputSchema: ValidateOutputSchema,
|
|
314
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
216
315
|
},
|
|
217
316
|
async (args) => {
|
|
218
317
|
const { source, filePath } = await sourceAndPath(args, allowedRoot);
|
|
219
318
|
const doc = await buildDocument(source);
|
|
220
319
|
const diagnostics = collectMcpDiagnostics(doc, filePath);
|
|
221
320
|
const ok = diagnostics.every((d) => d.severity !== 'error');
|
|
321
|
+
const insights = ok
|
|
322
|
+
? await collectMcpLayoutInsights({
|
|
323
|
+
source,
|
|
324
|
+
filePath,
|
|
325
|
+
today: todayUtc(),
|
|
326
|
+
locale: 'en-US',
|
|
327
|
+
readFile: createNodeHostEnv(filePath).readSource,
|
|
328
|
+
doc,
|
|
329
|
+
})
|
|
330
|
+
: [];
|
|
331
|
+
const structured = { ok, diagnostics, ...(insights.length > 0 ? { insights } : {}) };
|
|
222
332
|
return {
|
|
223
|
-
content: [
|
|
333
|
+
content: [
|
|
334
|
+
{ type: 'text', text: JSON.stringify(structured, null, 2) },
|
|
335
|
+
...(insights.length > 0
|
|
336
|
+
? [{ type: 'text' as const, text: LAYOUT_INSIGHT_HINT }]
|
|
337
|
+
: []),
|
|
338
|
+
],
|
|
339
|
+
structuredContent: structured,
|
|
224
340
|
};
|
|
225
341
|
},
|
|
226
342
|
);
|
|
@@ -236,17 +352,16 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
236
352
|
.string()
|
|
237
353
|
.describe('Absolute or relative path to the .nowline file to read.'),
|
|
238
354
|
}),
|
|
355
|
+
outputSchema: ReadOutputSchema,
|
|
356
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
239
357
|
},
|
|
240
358
|
async (args) => {
|
|
241
359
|
const abs = resolveAndGuard(args.path, allowedRoot);
|
|
242
360
|
const source = await fs.readFile(abs, 'utf-8');
|
|
361
|
+
const structured = { path: abs, source };
|
|
243
362
|
return {
|
|
244
|
-
content: [
|
|
245
|
-
|
|
246
|
-
type: 'text',
|
|
247
|
-
text: JSON.stringify({ path: abs, source }, null, 2),
|
|
248
|
-
},
|
|
249
|
-
],
|
|
363
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
364
|
+
structuredContent: structured,
|
|
250
365
|
};
|
|
251
366
|
},
|
|
252
367
|
);
|
|
@@ -256,33 +371,27 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
256
371
|
server.registerTool(
|
|
257
372
|
'create',
|
|
258
373
|
{
|
|
259
|
-
description:
|
|
374
|
+
description: toolDescriptionWithSyntax(
|
|
260
375
|
'Write a new .nowline file after validation. Overwrites if the path already exists.',
|
|
376
|
+
),
|
|
261
377
|
inputSchema: z.object({
|
|
262
378
|
path: z.string().describe('Absolute or relative path to write the .nowline file.'),
|
|
263
379
|
source: z.string().describe('The .nowline source text to write.'),
|
|
264
380
|
}),
|
|
381
|
+
outputSchema: CreateOutputSchema,
|
|
382
|
+
// Overwrites silently → destructive; same source always produces same file → idempotent.
|
|
383
|
+
annotations: { destructiveHint: true, idempotentHint: true },
|
|
265
384
|
},
|
|
266
385
|
async (args) => {
|
|
267
386
|
const abs = resolveAndGuard(args.path, allowedRoot);
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
const errors = diagnostics.filter((d) => d.severity === 'error');
|
|
271
|
-
if (errors.length > 0) {
|
|
272
|
-
return {
|
|
273
|
-
content: [
|
|
274
|
-
{
|
|
275
|
-
type: 'text',
|
|
276
|
-
text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
|
|
277
|
-
},
|
|
278
|
-
],
|
|
279
|
-
isError: true,
|
|
280
|
-
};
|
|
281
|
-
}
|
|
387
|
+
const blocked = await diagnosticsErrorBlock(args.source, abs);
|
|
388
|
+
if (!blocked.ok) return blocked.response;
|
|
282
389
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
283
390
|
await fs.writeFile(abs, args.source, 'utf-8');
|
|
391
|
+
const structured = { ok: true, path: abs };
|
|
284
392
|
return {
|
|
285
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
393
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
394
|
+
structuredContent: structured,
|
|
286
395
|
};
|
|
287
396
|
},
|
|
288
397
|
);
|
|
@@ -292,33 +401,27 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
292
401
|
server.registerTool(
|
|
293
402
|
'update',
|
|
294
403
|
{
|
|
295
|
-
description:
|
|
404
|
+
description: toolDescriptionWithSyntax(
|
|
405
|
+
'Replace an existing .nowline file after validation.',
|
|
406
|
+
),
|
|
296
407
|
inputSchema: z.object({
|
|
297
408
|
path: z
|
|
298
409
|
.string()
|
|
299
410
|
.describe('Absolute or relative path of the .nowline file to update.'),
|
|
300
411
|
source: z.string().describe('The new .nowline source text.'),
|
|
301
412
|
}),
|
|
413
|
+
outputSchema: UpdateOutputSchema,
|
|
414
|
+
annotations: { idempotentHint: true },
|
|
302
415
|
},
|
|
303
416
|
async (args) => {
|
|
304
417
|
const abs = resolveAndGuard(args.path, allowedRoot);
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
const errors = diagnostics.filter((d) => d.severity === 'error');
|
|
308
|
-
if (errors.length > 0) {
|
|
309
|
-
return {
|
|
310
|
-
content: [
|
|
311
|
-
{
|
|
312
|
-
type: 'text',
|
|
313
|
-
text: JSON.stringify({ ok: false, path: abs, diagnostics }, null, 2),
|
|
314
|
-
},
|
|
315
|
-
],
|
|
316
|
-
isError: true,
|
|
317
|
-
};
|
|
318
|
-
}
|
|
418
|
+
const blocked = await diagnosticsErrorBlock(args.source, abs);
|
|
419
|
+
if (!blocked.ok) return blocked.response;
|
|
319
420
|
await fs.writeFile(abs, args.source, 'utf-8');
|
|
421
|
+
const structured = { ok: true, path: abs };
|
|
320
422
|
return {
|
|
321
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
423
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
424
|
+
structuredContent: structured,
|
|
322
425
|
};
|
|
323
426
|
},
|
|
324
427
|
);
|
|
@@ -334,12 +437,16 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
334
437
|
.string()
|
|
335
438
|
.describe('Absolute or relative path of the .nowline file to delete.'),
|
|
336
439
|
}),
|
|
440
|
+
outputSchema: DeleteOutputSchema,
|
|
441
|
+
annotations: { destructiveHint: true },
|
|
337
442
|
},
|
|
338
443
|
async (args) => {
|
|
339
444
|
const abs = resolveAndGuard(args.path, allowedRoot);
|
|
340
445
|
await fs.unlink(abs);
|
|
446
|
+
const structured = { path: abs };
|
|
341
447
|
return {
|
|
342
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
448
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
449
|
+
structuredContent: structured,
|
|
343
450
|
};
|
|
344
451
|
},
|
|
345
452
|
);
|
|
@@ -362,27 +469,41 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
362
469
|
.optional()
|
|
363
470
|
.describe('Whether to scan subdirectories. Defaults to false.'),
|
|
364
471
|
}),
|
|
472
|
+
outputSchema: ListOutputSchema,
|
|
473
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
365
474
|
},
|
|
366
475
|
async (args) => {
|
|
367
476
|
const dir = args.directory ? resolveAndGuard(args.directory, allowedRoot) : allowedRoot;
|
|
368
477
|
const recursive = args.recursive ?? false;
|
|
369
478
|
const paths = await listNowlineFiles(dir, recursive);
|
|
479
|
+
const structured = { paths };
|
|
370
480
|
return {
|
|
371
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
481
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
482
|
+
structuredContent: structured,
|
|
372
483
|
};
|
|
373
484
|
},
|
|
374
485
|
);
|
|
375
486
|
|
|
376
487
|
// ---- render -------------------------------------------------------------
|
|
377
488
|
|
|
378
|
-
|
|
489
|
+
registerAppTool(
|
|
490
|
+
server,
|
|
379
491
|
'render',
|
|
380
492
|
{
|
|
381
|
-
description:
|
|
382
|
-
'
|
|
493
|
+
description: toolDescriptionWithSyntax(
|
|
494
|
+
'Validate then render a .nowline roadmap to SVG or PNG (combined validate+render+share). ' +
|
|
495
|
+
'Returns structured diagnostics on error-severity input instead of a raw kernel error.',
|
|
496
|
+
),
|
|
383
497
|
inputSchema: z.object({
|
|
384
498
|
source: z.string().optional().describe('Inline .nowline source text.'),
|
|
385
|
-
path: z
|
|
499
|
+
path: z
|
|
500
|
+
.string()
|
|
501
|
+
.optional()
|
|
502
|
+
.describe(
|
|
503
|
+
'Real local filesystem path to the .nowline file (e.g. /Users/name/Desktop/foo.nowline). ' +
|
|
504
|
+
'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
|
|
505
|
+
'those do not exist on the host filesystem. Pass `source` instead.',
|
|
506
|
+
),
|
|
386
507
|
format: z
|
|
387
508
|
.enum(['svg', 'png'])
|
|
388
509
|
.optional()
|
|
@@ -403,11 +524,42 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
403
524
|
output: z
|
|
404
525
|
.string()
|
|
405
526
|
.optional()
|
|
406
|
-
.describe(
|
|
527
|
+
.describe(
|
|
528
|
+
'Real local filesystem path to write the output file (e.g. /Users/name/Desktop/roadmap.svg). ' +
|
|
529
|
+
'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
|
|
530
|
+
'omit this parameter to receive output inline instead.',
|
|
531
|
+
),
|
|
532
|
+
share: z
|
|
533
|
+
.boolean()
|
|
534
|
+
.optional()
|
|
535
|
+
.describe(
|
|
536
|
+
'When true, include a shareUrl pointing to https://free.nowline.io/open.',
|
|
537
|
+
),
|
|
538
|
+
review: z
|
|
539
|
+
.boolean()
|
|
540
|
+
.optional()
|
|
541
|
+
.describe(
|
|
542
|
+
'When true, also attach a downscaled PNG so a multimodal model can visually check layout (label overflow, lane crowding, off-range now-line). Off by default.',
|
|
543
|
+
),
|
|
544
|
+
preview: z
|
|
545
|
+
.boolean()
|
|
546
|
+
.optional()
|
|
547
|
+
.describe(
|
|
548
|
+
'When true, force the in-chat MCP Apps preview. On MCP Apps hosts the preview auto-renders via _meta.ui without this flag.',
|
|
549
|
+
),
|
|
407
550
|
}),
|
|
551
|
+
outputSchema: RenderOutputSchema,
|
|
552
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
553
|
+
_meta: {
|
|
554
|
+
ui: { resourceUri: PREVIEW_UI_URI },
|
|
555
|
+
'openai/outputTemplate': PREVIEW_UI_URI,
|
|
556
|
+
},
|
|
408
557
|
},
|
|
409
558
|
async (args) => {
|
|
410
559
|
const { source, filePath } = await sourceAndPath(args, allowedRoot);
|
|
560
|
+
const blocked = await diagnosticsErrorBlock(source, filePath);
|
|
561
|
+
if (!blocked.ok) return blocked.response;
|
|
562
|
+
|
|
411
563
|
const format: ExportFormat = args.format ?? 'svg';
|
|
412
564
|
const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
|
|
413
565
|
const inputs: RenderInputs = {
|
|
@@ -418,28 +570,90 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
418
570
|
width: args.width,
|
|
419
571
|
pngScale: args.scale,
|
|
420
572
|
};
|
|
421
|
-
|
|
573
|
+
const host = createNodeHostEnv(filePath);
|
|
574
|
+
const appActive = args.preview === true || clientSupportsAppsUi(server);
|
|
575
|
+
// Skip the full render when the bytes won't be used: apps host with
|
|
576
|
+
// no write-to-disk path and no review attachment requested.
|
|
577
|
+
const needsRender = !appActive || !!args.output || args.review === true;
|
|
578
|
+
if (needsRender && (format === 'png' || args.review === true)) {
|
|
422
579
|
const result = await resolveFonts({ headless: true });
|
|
423
580
|
inputs.fonts = { sans: result.sans, mono: result.mono };
|
|
424
581
|
}
|
|
425
|
-
const
|
|
426
|
-
|
|
582
|
+
const bytes = needsRender
|
|
583
|
+
? await exportDocument(source, format, inputs, host)
|
|
584
|
+
: new Uint8Array(0);
|
|
585
|
+
const shareUrl = args.share
|
|
586
|
+
? (buildShareLink({ source, share: true }) ?? undefined)
|
|
587
|
+
: undefined;
|
|
588
|
+
|
|
589
|
+
const insights = await collectMcpLayoutInsights({
|
|
590
|
+
source,
|
|
591
|
+
filePath,
|
|
592
|
+
today,
|
|
593
|
+
theme: args.theme ?? 'light',
|
|
594
|
+
width: args.width,
|
|
595
|
+
locale: 'en-US',
|
|
596
|
+
readFile: host.readSource,
|
|
597
|
+
doc: blocked.doc,
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
const previewPayload: PreviewPayload = {
|
|
601
|
+
source,
|
|
602
|
+
theme: args.theme,
|
|
603
|
+
now: args.now,
|
|
604
|
+
width: args.width,
|
|
605
|
+
locale: 'en-US',
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const reviewBlocks =
|
|
609
|
+
args.review === true
|
|
610
|
+
? await buildReviewContentBlocks(source, format, bytes, inputs, host)
|
|
611
|
+
: [];
|
|
612
|
+
|
|
613
|
+
const insightHintBlocks =
|
|
614
|
+
insights.length > 0 ? [{ type: 'text' as const, text: LAYOUT_INSIGHT_HINT }] : [];
|
|
615
|
+
const insightsField = insights.length > 0 ? { insights } : {};
|
|
427
616
|
|
|
428
617
|
if (args.output) {
|
|
429
618
|
const outAbs = resolveAndGuard(args.output, allowedRoot);
|
|
430
619
|
await fs.mkdir(path.dirname(outAbs), { recursive: true });
|
|
431
620
|
await fs.writeFile(outAbs, bytes);
|
|
621
|
+
const structured = {
|
|
622
|
+
format,
|
|
623
|
+
path: outAbs,
|
|
624
|
+
bytes: bytes.byteLength,
|
|
625
|
+
shareUrl,
|
|
626
|
+
...insightsField,
|
|
627
|
+
};
|
|
432
628
|
return {
|
|
433
629
|
content: [
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
630
|
+
...(appActive ? [leanPreviewBlock(previewPayload)] : []),
|
|
631
|
+
{ type: 'text' as const, text: JSON.stringify(structured, null, 2) },
|
|
632
|
+
...insightHintBlocks,
|
|
633
|
+
...reviewBlocks,
|
|
438
634
|
],
|
|
635
|
+
structuredContent: structured,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (appActive) {
|
|
640
|
+
const structured = {
|
|
641
|
+
format,
|
|
642
|
+
shareUrl,
|
|
643
|
+
...insightsField,
|
|
644
|
+
};
|
|
645
|
+
return {
|
|
646
|
+
content: [
|
|
647
|
+
leanPreviewBlock(previewPayload),
|
|
648
|
+
...insightHintBlocks,
|
|
649
|
+
...reviewBlocks,
|
|
650
|
+
],
|
|
651
|
+
structuredContent: structured,
|
|
439
652
|
};
|
|
440
653
|
}
|
|
441
654
|
|
|
442
655
|
if (format === 'png') {
|
|
656
|
+
const structured = { format, bytes: bytes.byteLength, shareUrl, ...insightsField };
|
|
443
657
|
return {
|
|
444
658
|
content: [
|
|
445
659
|
{
|
|
@@ -447,17 +661,21 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
447
661
|
data: Buffer.from(bytes).toString('base64'),
|
|
448
662
|
mimeType: 'image/png',
|
|
449
663
|
},
|
|
664
|
+
...insightHintBlocks,
|
|
665
|
+
...reviewBlocks,
|
|
450
666
|
],
|
|
667
|
+
structuredContent: structured,
|
|
451
668
|
};
|
|
452
669
|
}
|
|
670
|
+
const svgText = new TextDecoder('utf-8').decode(bytes);
|
|
671
|
+
const structured = { format, shareUrl, ...insightsField };
|
|
453
672
|
return {
|
|
454
673
|
content: [
|
|
455
|
-
{
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
mimeType: 'image/svg+xml',
|
|
459
|
-
},
|
|
674
|
+
{ type: 'text', text: svgText, mimeType: 'image/svg+xml' },
|
|
675
|
+
...insightHintBlocks,
|
|
676
|
+
...reviewBlocks,
|
|
460
677
|
],
|
|
678
|
+
structuredContent: structured,
|
|
461
679
|
};
|
|
462
680
|
},
|
|
463
681
|
);
|
|
@@ -474,14 +692,26 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
474
692
|
'Export a .nowline roadmap to any of the eight canonical formats. Byte-identical to `nowline -f <format>` for the same source and inputs.',
|
|
475
693
|
inputSchema: z.object({
|
|
476
694
|
source: z.string().optional().describe('Inline .nowline source text.'),
|
|
477
|
-
path: z
|
|
695
|
+
path: z
|
|
696
|
+
.string()
|
|
697
|
+
.optional()
|
|
698
|
+
.describe(
|
|
699
|
+
'Real local filesystem path to the .nowline file (e.g. /Users/name/Desktop/foo.nowline). ' +
|
|
700
|
+
'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
|
|
701
|
+
'those do not exist on the host filesystem. Pass `source` instead.',
|
|
702
|
+
),
|
|
478
703
|
format: z
|
|
479
704
|
.enum(EXPORT_FORMATS)
|
|
480
705
|
.describe('Export format: pdf, html, mermaid, xlsx, msproj, or png.'),
|
|
481
706
|
output: z
|
|
482
707
|
.string()
|
|
483
708
|
.optional()
|
|
484
|
-
.describe(
|
|
709
|
+
.describe(
|
|
710
|
+
'Real local filesystem path to write the output (e.g. /Users/name/Desktop/roadmap.pdf). ' +
|
|
711
|
+
'Required for binary formats (pdf, xlsx, msproj, png). ' +
|
|
712
|
+
'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
|
|
713
|
+
'those do not exist on the host filesystem.',
|
|
714
|
+
),
|
|
485
715
|
now: z.string().optional().describe('Now-line date as YYYY-MM-DD (UTC).'),
|
|
486
716
|
theme: z.enum(['light', 'dark', 'grayscale']).optional(),
|
|
487
717
|
scale: z.number().optional().describe('PNG scale factor.'),
|
|
@@ -495,10 +725,21 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
495
725
|
.string()
|
|
496
726
|
.optional()
|
|
497
727
|
.describe('MS Project start date override (YYYY-MM-DD).'),
|
|
728
|
+
share: z
|
|
729
|
+
.boolean()
|
|
730
|
+
.optional()
|
|
731
|
+
.describe(
|
|
732
|
+
'When true, include a shareUrl pointing to https://free.nowline.io/open.',
|
|
733
|
+
),
|
|
498
734
|
}),
|
|
735
|
+
outputSchema: ExportOutputSchema,
|
|
736
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
499
737
|
},
|
|
500
738
|
async (args) => {
|
|
501
739
|
const { source, filePath } = await sourceAndPath(args, allowedRoot);
|
|
740
|
+
const blocked = await diagnosticsErrorBlock(source, filePath);
|
|
741
|
+
if (!blocked.ok) return blocked.response;
|
|
742
|
+
|
|
502
743
|
const format: ExportFormat = args.format as NonRenderFormat;
|
|
503
744
|
const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
|
|
504
745
|
const inputs: RenderInputs = {
|
|
@@ -518,6 +759,9 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
518
759
|
}
|
|
519
760
|
const host = createNodeHostEnv(filePath);
|
|
520
761
|
const bytes = await exportDocument(source, format, inputs, host);
|
|
762
|
+
const shareUrl = args.share
|
|
763
|
+
? (buildShareLink({ source, share: true }) ?? undefined)
|
|
764
|
+
: undefined;
|
|
521
765
|
|
|
522
766
|
const BINARY_FORMATS = new Set<ExportFormat>(['png', 'pdf', 'xlsx']);
|
|
523
767
|
const isBinary = BINARY_FORMATS.has(format);
|
|
@@ -526,13 +770,10 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
526
770
|
const outAbs = resolveAndGuard(args.output, allowedRoot);
|
|
527
771
|
await fs.mkdir(path.dirname(outAbs), { recursive: true });
|
|
528
772
|
await fs.writeFile(outAbs, bytes);
|
|
773
|
+
const structured = { format, path: outAbs, bytes: bytes.byteLength, shareUrl };
|
|
529
774
|
return {
|
|
530
|
-
content: [
|
|
531
|
-
|
|
532
|
-
type: 'text',
|
|
533
|
-
text: JSON.stringify({ path: outAbs, bytes: bytes.byteLength }),
|
|
534
|
-
},
|
|
535
|
-
],
|
|
775
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
776
|
+
structuredContent: structured,
|
|
536
777
|
};
|
|
537
778
|
}
|
|
538
779
|
|
|
@@ -542,6 +783,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
542
783
|
pdf: 'application/pdf',
|
|
543
784
|
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
544
785
|
};
|
|
786
|
+
const structured = { format, bytes: bytes.byteLength, shareUrl };
|
|
545
787
|
return {
|
|
546
788
|
content: [
|
|
547
789
|
{
|
|
@@ -550,15 +792,310 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
550
792
|
mimeType: mimeMap[format] ?? 'application/octet-stream',
|
|
551
793
|
},
|
|
552
794
|
],
|
|
795
|
+
structuredContent: structured,
|
|
553
796
|
};
|
|
554
797
|
}
|
|
798
|
+
const text = new TextDecoder('utf-8').decode(bytes);
|
|
799
|
+
const structured = { format, shareUrl };
|
|
800
|
+
return {
|
|
801
|
+
content: [{ type: 'text', text }],
|
|
802
|
+
structuredContent: structured,
|
|
803
|
+
};
|
|
804
|
+
},
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
// ---- convert ------------------------------------------------------------
|
|
808
|
+
|
|
809
|
+
server.registerTool(
|
|
810
|
+
'convert',
|
|
811
|
+
{
|
|
812
|
+
description:
|
|
813
|
+
'Convert between .nowline source text and its JSON AST representation. `to:json` serializes a .nowline file to the JSON AST; `to:nowline` pretty-prints a JSON AST back to canonical .nowline source.',
|
|
814
|
+
inputSchema: z.object({
|
|
815
|
+
source: z.string().optional().describe('Inline source text to convert.'),
|
|
816
|
+
path: z
|
|
817
|
+
.string()
|
|
818
|
+
.optional()
|
|
819
|
+
.describe(
|
|
820
|
+
'Real local filesystem path to the source file. ' +
|
|
821
|
+
'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
|
|
822
|
+
'pass `source` instead.',
|
|
823
|
+
),
|
|
824
|
+
to: z
|
|
825
|
+
.enum(['json', 'nowline'])
|
|
826
|
+
.describe(
|
|
827
|
+
'"json" — serialize .nowline text to JSON AST. "nowline" — pretty-print a JSON AST back to .nowline source.',
|
|
828
|
+
),
|
|
829
|
+
}),
|
|
830
|
+
outputSchema: ConvertOutputSchema,
|
|
831
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
832
|
+
},
|
|
833
|
+
async (args) => {
|
|
834
|
+
if (args.to === 'json') {
|
|
835
|
+
const { source, filePath } = await sourceAndPath(args, allowedRoot);
|
|
836
|
+
const host = createNodeHostEnv(filePath);
|
|
837
|
+
const jsonBytes = await exportDocument(
|
|
838
|
+
source,
|
|
839
|
+
'json',
|
|
840
|
+
{
|
|
841
|
+
sourcePath: filePath,
|
|
842
|
+
today: todayUtc(),
|
|
843
|
+
locale: 'en-US',
|
|
844
|
+
theme: 'light',
|
|
845
|
+
},
|
|
846
|
+
host,
|
|
847
|
+
);
|
|
848
|
+
const result = new TextDecoder('utf-8').decode(jsonBytes);
|
|
849
|
+
const structured = { to: 'json' as const, result };
|
|
850
|
+
return {
|
|
851
|
+
content: [{ type: 'text', text: result }],
|
|
852
|
+
structuredContent: structured,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// to: 'nowline' — input is a JSON AST string
|
|
857
|
+
const jsonSource =
|
|
858
|
+
args.source ??
|
|
859
|
+
(args.path
|
|
860
|
+
? await fs.readFile(resolveAndGuard(args.path, allowedRoot), 'utf-8')
|
|
861
|
+
: null);
|
|
862
|
+
if (!jsonSource) {
|
|
863
|
+
throw new Error('At least one of `source` or `path` is required.');
|
|
864
|
+
}
|
|
865
|
+
const { ast } = parseNowlineJson(jsonSource, args.path ?? 'input.json');
|
|
866
|
+
const result = printNowlineFile(ast);
|
|
867
|
+
const structured = { to: 'nowline' as const, result };
|
|
868
|
+
return {
|
|
869
|
+
content: [{ type: 'text', text: result }],
|
|
870
|
+
structuredContent: structured,
|
|
871
|
+
};
|
|
872
|
+
},
|
|
873
|
+
);
|
|
874
|
+
|
|
875
|
+
// ---- capabilities -------------------------------------------------------
|
|
876
|
+
|
|
877
|
+
server.registerTool(
|
|
878
|
+
'capabilities',
|
|
879
|
+
{
|
|
880
|
+
description:
|
|
881
|
+
'Return all supported themes, icons, locales, export formats, and template names in a single response.',
|
|
882
|
+
inputSchema: z.object({}),
|
|
883
|
+
outputSchema: CapabilitiesOutputSchema,
|
|
884
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
885
|
+
},
|
|
886
|
+
async () => {
|
|
887
|
+
const structured = {
|
|
888
|
+
themes: [...CAPABILITIES.themes],
|
|
889
|
+
icons: [...CAPABILITIES.icons],
|
|
890
|
+
locales: [...CAPABILITIES.locales],
|
|
891
|
+
formats: [...CAPABILITIES.formats],
|
|
892
|
+
templates: [...CAPABILITIES.templates],
|
|
893
|
+
};
|
|
894
|
+
return {
|
|
895
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
896
|
+
structuredContent: structured,
|
|
897
|
+
};
|
|
898
|
+
},
|
|
899
|
+
);
|
|
900
|
+
|
|
901
|
+
// ---- list-themes --------------------------------------------------------
|
|
902
|
+
|
|
903
|
+
server.registerTool(
|
|
904
|
+
'list-themes',
|
|
905
|
+
{
|
|
906
|
+
description: 'List supported color themes: light, dark, grayscale.',
|
|
907
|
+
inputSchema: z.object({}),
|
|
908
|
+
outputSchema: ListItemsOutputSchema,
|
|
909
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
910
|
+
},
|
|
911
|
+
async () => {
|
|
912
|
+
const structured = { items: [...CAPABILITIES.themes] };
|
|
913
|
+
return {
|
|
914
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
915
|
+
structuredContent: structured,
|
|
916
|
+
};
|
|
917
|
+
},
|
|
918
|
+
);
|
|
919
|
+
|
|
920
|
+
// ---- list-icons ---------------------------------------------------------
|
|
921
|
+
|
|
922
|
+
server.registerTool(
|
|
923
|
+
'list-icons',
|
|
924
|
+
{
|
|
925
|
+
description:
|
|
926
|
+
'List built-in capacity-icon names usable in the `capacity-icon:` style property.',
|
|
927
|
+
inputSchema: z.object({}),
|
|
928
|
+
outputSchema: ListItemsOutputSchema,
|
|
929
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
930
|
+
},
|
|
931
|
+
async () => {
|
|
932
|
+
const structured = { items: [...CAPABILITIES.icons] };
|
|
933
|
+
return {
|
|
934
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
935
|
+
structuredContent: structured,
|
|
936
|
+
};
|
|
937
|
+
},
|
|
938
|
+
);
|
|
939
|
+
|
|
940
|
+
// ---- list-locales -------------------------------------------------------
|
|
941
|
+
|
|
942
|
+
server.registerTool(
|
|
943
|
+
'list-locales',
|
|
944
|
+
{
|
|
945
|
+
description: 'List supported BCP-47 locale tags.',
|
|
946
|
+
inputSchema: z.object({}),
|
|
947
|
+
outputSchema: ListItemsOutputSchema,
|
|
948
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
949
|
+
},
|
|
950
|
+
async () => {
|
|
951
|
+
const structured = { items: [...CAPABILITIES.locales] };
|
|
952
|
+
return {
|
|
953
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
954
|
+
structuredContent: structured,
|
|
955
|
+
};
|
|
956
|
+
},
|
|
957
|
+
);
|
|
958
|
+
|
|
959
|
+
// ---- list-formats -------------------------------------------------------
|
|
960
|
+
|
|
961
|
+
server.registerTool(
|
|
962
|
+
'list-formats',
|
|
963
|
+
{
|
|
964
|
+
description:
|
|
965
|
+
'List all supported export formats (svg, png, pdf, html, mermaid, xlsx, msproj, json).',
|
|
966
|
+
inputSchema: z.object({}),
|
|
967
|
+
outputSchema: ListItemsOutputSchema,
|
|
968
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
969
|
+
},
|
|
970
|
+
async () => {
|
|
971
|
+
const structured = { items: [...CAPABILITIES.formats] };
|
|
972
|
+
return {
|
|
973
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
974
|
+
structuredContent: structured,
|
|
975
|
+
};
|
|
976
|
+
},
|
|
977
|
+
);
|
|
978
|
+
|
|
979
|
+
// ---- list-templates -----------------------------------------------------
|
|
980
|
+
|
|
981
|
+
server.registerTool(
|
|
982
|
+
'list-templates',
|
|
983
|
+
{
|
|
984
|
+
description: 'List built-in template names usable with `nowline --init --template`.',
|
|
985
|
+
inputSchema: z.object({}),
|
|
986
|
+
outputSchema: ListItemsOutputSchema,
|
|
987
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
988
|
+
},
|
|
989
|
+
async () => {
|
|
990
|
+
const structured = { items: [...CAPABILITIES.templates] };
|
|
991
|
+
return {
|
|
992
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
993
|
+
structuredContent: structured,
|
|
994
|
+
};
|
|
995
|
+
},
|
|
996
|
+
);
|
|
997
|
+
|
|
998
|
+
// ---- reference / examples / schema (discovery tools) --------------------
|
|
999
|
+
|
|
1000
|
+
server.registerTool(
|
|
1001
|
+
'reference',
|
|
1002
|
+
{
|
|
1003
|
+
description:
|
|
1004
|
+
'Return the Nowline DSL reference (condensed cheatsheet or full man page). Callable alternative to the nowline://reference resource.',
|
|
1005
|
+
inputSchema: z.object({
|
|
1006
|
+
format: z
|
|
1007
|
+
.enum(['condensed', 'full'])
|
|
1008
|
+
.optional()
|
|
1009
|
+
.describe('Reference format. Defaults to condensed.'),
|
|
1010
|
+
}),
|
|
1011
|
+
outputSchema: ReferenceOutputSchema,
|
|
1012
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
1013
|
+
},
|
|
1014
|
+
async (args) => {
|
|
1015
|
+
const format = args.format ?? 'condensed';
|
|
1016
|
+
const text = format === 'full' ? REFERENCE_MAN_PAGE : REFERENCE_CHEATSHEET;
|
|
1017
|
+
const structured = { format, text };
|
|
1018
|
+
return {
|
|
1019
|
+
content: [{ type: 'text', text }],
|
|
1020
|
+
structuredContent: structured,
|
|
1021
|
+
};
|
|
1022
|
+
},
|
|
1023
|
+
);
|
|
1024
|
+
|
|
1025
|
+
server.registerTool(
|
|
1026
|
+
'examples',
|
|
1027
|
+
{
|
|
1028
|
+
description:
|
|
1029
|
+
'Return canonical .nowline example sources. Callable alternative to the nowline://examples resource.',
|
|
1030
|
+
inputSchema: z.object({
|
|
1031
|
+
name: z
|
|
1032
|
+
.string()
|
|
1033
|
+
.optional()
|
|
1034
|
+
.describe('Example name. Omit for the catalog plus minimal inline.'),
|
|
1035
|
+
}),
|
|
1036
|
+
outputSchema: ExamplesOutputSchema,
|
|
1037
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
1038
|
+
},
|
|
1039
|
+
async (args) => {
|
|
1040
|
+
const exampleNames = EXAMPLES.map((e) => exampleShortName(e.name));
|
|
1041
|
+
if (args.name) {
|
|
1042
|
+
const ex = findExample(args.name);
|
|
1043
|
+
if (!ex) {
|
|
1044
|
+
return {
|
|
1045
|
+
content: [
|
|
1046
|
+
{
|
|
1047
|
+
type: 'text',
|
|
1048
|
+
text: JSON.stringify({
|
|
1049
|
+
error: `Unknown example "${args.name}".`,
|
|
1050
|
+
names: exampleNames,
|
|
1051
|
+
}),
|
|
1052
|
+
},
|
|
1053
|
+
],
|
|
1054
|
+
isError: true,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
const structured = { name: exampleShortName(ex.name), source: ex.content };
|
|
1058
|
+
return {
|
|
1059
|
+
content: [{ type: 'text', text: ex.content }],
|
|
1060
|
+
structuredContent: structured,
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
const minimal = findExample('minimal') ?? EXAMPLES[0];
|
|
1064
|
+
const structured = {
|
|
1065
|
+
names: exampleNames,
|
|
1066
|
+
name: exampleShortName(minimal.name),
|
|
1067
|
+
source: minimal.content,
|
|
1068
|
+
};
|
|
555
1069
|
return {
|
|
556
1070
|
content: [
|
|
557
1071
|
{
|
|
558
1072
|
type: 'text',
|
|
559
|
-
text:
|
|
1073
|
+
text: `# Examples\n\n${exampleNames.map((n) => `- ${n}`).join('\n')}\n\n## ${structured.name}\n\n${minimal.content}`,
|
|
560
1074
|
},
|
|
561
1075
|
],
|
|
1076
|
+
structuredContent: structured,
|
|
1077
|
+
};
|
|
1078
|
+
},
|
|
1079
|
+
);
|
|
1080
|
+
|
|
1081
|
+
server.registerTool(
|
|
1082
|
+
'schema',
|
|
1083
|
+
{
|
|
1084
|
+
description:
|
|
1085
|
+
'Return the structured Nowline DSL key vocabulary (directive keys, entity types, item properties).',
|
|
1086
|
+
inputSchema: z.object({}),
|
|
1087
|
+
outputSchema: SchemaOutputSchema,
|
|
1088
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
1089
|
+
},
|
|
1090
|
+
async () => {
|
|
1091
|
+
const structured = {
|
|
1092
|
+
directiveKeys: [...SCHEMA_VOCABULARY.directiveKeys],
|
|
1093
|
+
entityTypes: [...SCHEMA_VOCABULARY.entityTypes],
|
|
1094
|
+
itemPropertyKeys: [...SCHEMA_VOCABULARY.itemPropertyKeys],
|
|
1095
|
+
};
|
|
1096
|
+
return {
|
|
1097
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
1098
|
+
structuredContent: structured,
|
|
562
1099
|
};
|
|
563
1100
|
},
|
|
564
1101
|
);
|
|
@@ -568,6 +1105,55 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
568
1105
|
|
|
569
1106
|
// ---- Helpers ----------------------------------------------------------------
|
|
570
1107
|
|
|
1108
|
+
type ContentBlock =
|
|
1109
|
+
| { type: 'text'; text: string; mimeType?: string }
|
|
1110
|
+
| { type: 'image'; data: string; mimeType: string };
|
|
1111
|
+
|
|
1112
|
+
async function buildReviewContentBlocks(
|
|
1113
|
+
source: string,
|
|
1114
|
+
format: ExportFormat,
|
|
1115
|
+
artifactBytes: Uint8Array,
|
|
1116
|
+
inputs: RenderInputs,
|
|
1117
|
+
host: HostEnv,
|
|
1118
|
+
): Promise<ContentBlock[]> {
|
|
1119
|
+
const blocks: ContentBlock[] = [
|
|
1120
|
+
{
|
|
1121
|
+
type: 'text',
|
|
1122
|
+
text:
|
|
1123
|
+
'Review this raster for layout issues (truncated labels, crowded lanes, ' +
|
|
1124
|
+
'now-line position) before finalizing.',
|
|
1125
|
+
},
|
|
1126
|
+
];
|
|
1127
|
+
|
|
1128
|
+
const artifactWidth = inputs.width ?? DEFAULT_RENDER_WIDTH;
|
|
1129
|
+
let inspectionBytes: Uint8Array;
|
|
1130
|
+
|
|
1131
|
+
if (format === 'png' && artifactWidth <= REVIEW_MAX_WIDTH) {
|
|
1132
|
+
inspectionBytes = artifactBytes;
|
|
1133
|
+
} else {
|
|
1134
|
+
const reviewInputs: RenderInputs = {
|
|
1135
|
+
...inputs,
|
|
1136
|
+
width: Math.min(artifactWidth, REVIEW_MAX_WIDTH),
|
|
1137
|
+
pngScale: 1,
|
|
1138
|
+
};
|
|
1139
|
+
if (!reviewInputs.fonts) {
|
|
1140
|
+
const fonts = await resolveFonts({ headless: true });
|
|
1141
|
+
reviewInputs.fonts = { sans: fonts.sans, mono: fonts.mono };
|
|
1142
|
+
}
|
|
1143
|
+
inspectionBytes = await exportDocument(source, 'png', reviewInputs, host);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (format !== 'png' || inspectionBytes.byteLength !== artifactBytes.byteLength) {
|
|
1147
|
+
blocks.push({
|
|
1148
|
+
type: 'image',
|
|
1149
|
+
data: Buffer.from(inspectionBytes).toString('base64'),
|
|
1150
|
+
mimeType: 'image/png',
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return blocks;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
571
1157
|
async function listNowlineFiles(dir: string, recursive: boolean): Promise<string[]> {
|
|
572
1158
|
const results: string[] = [];
|
|
573
1159
|
try {
|