@nowline/mcp 0.8.2 → 0.8.4
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/generated/ui-bundle.d.ts.map +1 -1
- package/dist/generated/ui-bundle.js +2 -2
- package/dist/generated/ui-bundle.js.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/root-path.d.ts +13 -0
- package/dist/root-path.d.ts.map +1 -0
- package/dist/root-path.js +58 -0
- package/dist/root-path.js.map +1 -0
- package/dist/schemas.d.ts +3 -2
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +3 -2
- 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 +201 -56
- package/dist/server.js.map +1 -1
- package/package.json +9 -9
- package/src/generated/ui-bundle.ts +2 -2
- package/src/index.ts +11 -1
- package/src/root-path.ts +64 -0
- package/src/schemas.ts +4 -2
- package/src/server.ts +232 -59
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { dirname, resolve } from 'node:path';
|
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
18
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
19
19
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
20
|
+
import { expandRootPath } from './root-path.js';
|
|
20
21
|
import { createMcpServer } from './server.js';
|
|
21
22
|
|
|
22
23
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -90,7 +91,16 @@ if (help) {
|
|
|
90
91
|
process.exit(0);
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
|
|
94
|
+
// Expand mcpb tokens (${HOME}/~/env vars) so a literal default from the
|
|
95
|
+
// .mcpb directory picker resolves to a real path. An empty/blank value
|
|
96
|
+
// (optional field left unset) collapses to undefined → cwd fallback, inline.
|
|
97
|
+
const expandedRoot = expandRootPath(root);
|
|
98
|
+
|
|
99
|
+
const server = createMcpServer({
|
|
100
|
+
allowedRoot: expandedRoot,
|
|
101
|
+
rootConfigured: expandedRoot !== undefined,
|
|
102
|
+
version: PKG_VERSION,
|
|
103
|
+
});
|
|
94
104
|
|
|
95
105
|
if (port !== undefined) {
|
|
96
106
|
// Streamable HTTP transport — stateless (no session management).
|
package/src/root-path.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Resolves the --root argument into a real absolute path.
|
|
2
|
+
//
|
|
3
|
+
// Claude Desktop's .mcpb variable substitution is single-pass: a `user_config`
|
|
4
|
+
// default like "${HOME}/Downloads" can reach the server with `${HOME}` still
|
|
5
|
+
// unexpanded when the user installs the bundle without opening the directory
|
|
6
|
+
// picker (the optional field's raw default is used verbatim for the
|
|
7
|
+
// `${user_config.*}` substitution, and the nested token is never re-expanded).
|
|
8
|
+
// To stay robust regardless of host behavior we expand the mcpb special tokens,
|
|
9
|
+
// a leading `~`, and generic environment variables here.
|
|
10
|
+
//
|
|
11
|
+
// Spec: specs/mcp.md ".mcpb bundle packaging".
|
|
12
|
+
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join, resolve, sep } from 'node:path';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Expands `--root` into an absolute path, or returns `undefined` when no
|
|
18
|
+
* usable root was provided (undefined or empty — e.g. an optional `.mcpb`
|
|
19
|
+
* directory field left blank, which the host passes as an empty string).
|
|
20
|
+
*
|
|
21
|
+
* Handled tokens:
|
|
22
|
+
* - mcpb specials: `${HOME}`, `${DESKTOP}`, `${DOCUMENTS}`, `${DOWNLOADS}`,
|
|
23
|
+
* `${pathSeparator}`, `${/}`
|
|
24
|
+
* - generic environment variables: `${NAME}` / `$NAME` (uppercase)
|
|
25
|
+
* - a leading `~` / `~/`
|
|
26
|
+
*/
|
|
27
|
+
export function expandRootPath(raw: string | undefined): string | undefined {
|
|
28
|
+
if (raw === undefined) return undefined;
|
|
29
|
+
const trimmed = raw.trim();
|
|
30
|
+
if (trimmed === '') return undefined;
|
|
31
|
+
|
|
32
|
+
const home = homedir();
|
|
33
|
+
const specials: Record<string, string> = {
|
|
34
|
+
HOME: home,
|
|
35
|
+
DESKTOP: join(home, 'Desktop'),
|
|
36
|
+
DOCUMENTS: join(home, 'Documents'),
|
|
37
|
+
DOWNLOADS: join(home, 'Downloads'),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let out = trimmed;
|
|
41
|
+
|
|
42
|
+
// mcpb path-separator tokens.
|
|
43
|
+
out = out.replace(/\$\{pathSeparator\}/g, sep).replace(/\$\{\/\}/g, sep);
|
|
44
|
+
|
|
45
|
+
// ${NAME} / $NAME — mcpb special dirs first, then generic env vars.
|
|
46
|
+
out = out.replace(
|
|
47
|
+
/\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/g,
|
|
48
|
+
(match, braced: string | undefined, bare: string | undefined) => {
|
|
49
|
+
const name = braced ?? bare ?? '';
|
|
50
|
+
if (name in specials) return specials[name];
|
|
51
|
+
const env = process.env[name];
|
|
52
|
+
return env !== undefined ? env : match;
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Leading ~ / ~/.
|
|
57
|
+
if (out === '~') {
|
|
58
|
+
out = home;
|
|
59
|
+
} else if (out.startsWith('~/') || out.startsWith(`~${sep}`)) {
|
|
60
|
+
out = join(home, out.slice(2));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return resolve(out);
|
|
64
|
+
}
|
package/src/schemas.ts
CHANGED
|
@@ -60,7 +60,6 @@ export const RenderOutputSchema = z.object({
|
|
|
60
60
|
/** Set when the output was written to disk. */
|
|
61
61
|
path: z.string().optional(),
|
|
62
62
|
bytes: z.number().optional(),
|
|
63
|
-
shareUrl: z.string().optional(),
|
|
64
63
|
insights: z.array(InsightSchema).optional(),
|
|
65
64
|
});
|
|
66
65
|
|
|
@@ -68,7 +67,10 @@ export const ExportOutputSchema = z.object({
|
|
|
68
67
|
format: z.string(),
|
|
69
68
|
path: z.string().optional(),
|
|
70
69
|
bytes: z.number().optional(),
|
|
71
|
-
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const ShareOutputSchema = z.object({
|
|
73
|
+
shareUrl: z.string(),
|
|
72
74
|
});
|
|
73
75
|
|
|
74
76
|
export const ConvertOutputSchema = z.object({
|
package/src/server.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
registerAppTool,
|
|
16
16
|
} from '@modelcontextprotocol/ext-apps/server';
|
|
17
17
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
|
-
import { parseNowlineJson, printNowlineFile } from '@nowline/core';
|
|
18
|
+
import { type NowlineFile, parseNowlineJson, printNowlineFile } from '@nowline/core';
|
|
19
19
|
import {
|
|
20
20
|
type ExportFormat,
|
|
21
21
|
exportDocument,
|
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
ReferenceOutputSchema,
|
|
64
64
|
RenderOutputSchema,
|
|
65
65
|
SchemaOutputSchema,
|
|
66
|
+
ShareOutputSchema,
|
|
66
67
|
UpdateOutputSchema,
|
|
67
68
|
ValidateOutputSchema,
|
|
68
69
|
} from './schemas.js';
|
|
@@ -218,6 +219,8 @@ async function sourceAndPath(
|
|
|
218
219
|
export interface McpServerOptions {
|
|
219
220
|
/** Working directory — all file paths are resolved relative to this root. Defaults to process.cwd(). */
|
|
220
221
|
allowedRoot?: string;
|
|
222
|
+
/** Whether the allowed root was explicitly configured (vs. defaulting to cwd). Controls smart export delivery for binary formats. Defaults to false. */
|
|
223
|
+
rootConfigured?: boolean;
|
|
221
224
|
/** Server name shown in the MCP client. Defaults to 'nowline'. */
|
|
222
225
|
name?: string;
|
|
223
226
|
/** Server version. Defaults to package version. */
|
|
@@ -226,6 +229,7 @@ export interface McpServerOptions {
|
|
|
226
229
|
|
|
227
230
|
export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
228
231
|
const allowedRoot = opts.allowedRoot ?? process.cwd();
|
|
232
|
+
const rootConfigured = opts.rootConfigured ?? false;
|
|
229
233
|
const server = new McpServer(
|
|
230
234
|
{
|
|
231
235
|
name: opts.name ?? 'nowline',
|
|
@@ -239,7 +243,16 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
239
243
|
'Workflow: 1. call `reference` or `examples` to learn syntax → 2. write `.nowline` → ' +
|
|
240
244
|
'3. call `render` (validates + renders; or `validate` alone) → 4. fix errors keyed on `NL.E####` ' +
|
|
241
245
|
'and re-render → 5. review returned layout `insights` (what reflowed) → 6. when uncertain, ' +
|
|
242
|
-
'call `render` with `review:true` for a final visual check. JSON in `convert` is AST conversion only.'
|
|
246
|
+
'call `render` with `review:true` for a final visual check. JSON in `convert` is AST conversion only. ' +
|
|
247
|
+
'Presenting output: default to `render` (in-chat preview / inline image) — the primary presentation. ' +
|
|
248
|
+
'The in-chat preview is view-only (no download/export button). Use `export` to produce a file. ' +
|
|
249
|
+
'Export files: binary formats (pdf, xlsx, png) are auto-saved to the configured folder — omit `delivery` ' +
|
|
250
|
+
'and omit `output` to let the server save the file; never use `delivery:"inline"` for pdf/xlsx on Claude ' +
|
|
251
|
+
'Desktop (the host cannot display inline documents, so nothing appears). For svg/html/mermaid exports, ' +
|
|
252
|
+
'present the returned text to the user as a downloadable artifact of the matching type ' +
|
|
253
|
+
'(.svg / .html / .mermaid) so they can save it. ' +
|
|
254
|
+
'Use the `share` tool only when the user explicitly wants a link they can open or share ' +
|
|
255
|
+
'(opens the free Nowline web app, where they can also export).',
|
|
243
256
|
},
|
|
244
257
|
);
|
|
245
258
|
|
|
@@ -554,8 +567,9 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
554
567
|
'render',
|
|
555
568
|
{
|
|
556
569
|
description: toolDescriptionWithSyntax(
|
|
557
|
-
'Validate then render a .nowline roadmap to SVG or PNG (combined validate+render
|
|
558
|
-
'Returns structured diagnostics on error-severity input instead of a raw kernel error.'
|
|
570
|
+
'Validate then render a .nowline roadmap to SVG or PNG (combined validate+render). ' +
|
|
571
|
+
'Returns structured diagnostics on error-severity input instead of a raw kernel error. ' +
|
|
572
|
+
'On MCP Apps hosts the in-chat preview is view-only (no download/export button); use `export` for files and the `share` tool for an openable link.',
|
|
559
573
|
),
|
|
560
574
|
inputSchema: z.object({
|
|
561
575
|
source: z.string().optional().describe('Inline .nowline source text.'),
|
|
@@ -592,12 +606,6 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
592
606
|
'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
|
|
593
607
|
'omit this parameter to receive output inline instead.',
|
|
594
608
|
),
|
|
595
|
-
share: z
|
|
596
|
-
.boolean()
|
|
597
|
-
.optional()
|
|
598
|
-
.describe(
|
|
599
|
-
'When true, include a shareUrl pointing to https://free.nowline.io/open.',
|
|
600
|
-
),
|
|
601
609
|
review: z
|
|
602
610
|
.boolean()
|
|
603
611
|
.optional()
|
|
@@ -655,9 +663,6 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
655
663
|
const bytes = needsRender
|
|
656
664
|
? await exportDocument(source, format, inputs, host)
|
|
657
665
|
: new Uint8Array(0);
|
|
658
|
-
const shareUrl = args.share
|
|
659
|
-
? (buildShareLink({ source, share: true }) ?? undefined)
|
|
660
|
-
: undefined;
|
|
661
666
|
|
|
662
667
|
const insights = await collectMcpLayoutInsights({
|
|
663
668
|
source,
|
|
@@ -696,7 +701,6 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
696
701
|
format,
|
|
697
702
|
path: outAbs,
|
|
698
703
|
bytes: bytes.byteLength,
|
|
699
|
-
shareUrl,
|
|
700
704
|
...insightsField,
|
|
701
705
|
};
|
|
702
706
|
return {
|
|
@@ -716,7 +720,6 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
716
720
|
if (appActive) {
|
|
717
721
|
const structured = {
|
|
718
722
|
format,
|
|
719
|
-
shareUrl,
|
|
720
723
|
...insightsField,
|
|
721
724
|
};
|
|
722
725
|
return {
|
|
@@ -733,7 +736,6 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
733
736
|
const structured = {
|
|
734
737
|
format,
|
|
735
738
|
bytes: bytes.byteLength,
|
|
736
|
-
shareUrl,
|
|
737
739
|
...insightsField,
|
|
738
740
|
};
|
|
739
741
|
return {
|
|
@@ -750,7 +752,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
750
752
|
};
|
|
751
753
|
}
|
|
752
754
|
const svgText = new TextDecoder('utf-8').decode(bytes);
|
|
753
|
-
const structured = { format,
|
|
755
|
+
const structured = { format, ...insightsField };
|
|
754
756
|
return {
|
|
755
757
|
content: [
|
|
756
758
|
{ type: 'text', text: svgText, mimeType: 'image/svg+xml' },
|
|
@@ -764,14 +766,17 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
764
766
|
|
|
765
767
|
// ---- export -------------------------------------------------------------
|
|
766
768
|
|
|
767
|
-
const EXPORT_FORMATS = ['pdf', 'html', 'mermaid', 'xlsx', 'msproj', 'png'] as const;
|
|
769
|
+
const EXPORT_FORMATS = ['svg', 'pdf', 'html', 'mermaid', 'xlsx', 'msproj', 'png'] as const;
|
|
768
770
|
type NonRenderFormat = (typeof EXPORT_FORMATS)[number];
|
|
769
771
|
|
|
770
772
|
server.registerTool(
|
|
771
773
|
'export',
|
|
772
774
|
{
|
|
773
775
|
description:
|
|
774
|
-
'Export a .nowline roadmap to any of the eight canonical formats. Byte-identical to `nowline -f <format>` for the same source and inputs.'
|
|
776
|
+
'Export a .nowline roadmap to any of the eight canonical formats. Byte-identical to `nowline -f <format>` for the same source and inputs. ' +
|
|
777
|
+
'For pdf/xlsx/png: omit `delivery` and omit `output` — the server auto-saves to the configured folder and returns the path. ' +
|
|
778
|
+
'Never use `delivery:"inline"` for binary formats on Claude Desktop (inline binary cannot be displayed). ' +
|
|
779
|
+
'For svg/html/mermaid: the text is returned inline — present it to the user as a downloadable artifact of the matching type.',
|
|
775
780
|
inputSchema: z.object({
|
|
776
781
|
source: z.string().optional().describe('Inline .nowline source text.'),
|
|
777
782
|
path: z
|
|
@@ -784,15 +789,16 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
784
789
|
),
|
|
785
790
|
format: z
|
|
786
791
|
.enum(EXPORT_FORMATS)
|
|
787
|
-
.describe('Export format: pdf, html, mermaid, xlsx, msproj, or png.'),
|
|
792
|
+
.describe('Export format: svg, pdf, html, mermaid, xlsx, msproj, or png.'),
|
|
788
793
|
output: z
|
|
789
794
|
.string()
|
|
790
795
|
.optional()
|
|
791
796
|
.describe(
|
|
792
|
-
'
|
|
793
|
-
'
|
|
794
|
-
'
|
|
795
|
-
'
|
|
797
|
+
'Optional path to write the output file. Omit for binary formats (pdf, xlsx, png) — ' +
|
|
798
|
+
'the server auto-saves to the configured folder as <roadmap-id>.<ext>. ' +
|
|
799
|
+
'To choose a filename, pass a bare filename (e.g. "roadmap.pdf") — it is saved into the configured folder. ' +
|
|
800
|
+
'Never pass a virtual or sandbox path such as /home/claude/…, /mnt/user-data/…, or any path ' +
|
|
801
|
+
'outside the configured output folder — those do not exist on the host filesystem.',
|
|
796
802
|
),
|
|
797
803
|
now: z.string().optional().describe('Now-line date as YYYY-MM-DD (UTC).'),
|
|
798
804
|
theme: z.enum(['light', 'dark', 'grayscale']).optional(),
|
|
@@ -807,11 +813,16 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
807
813
|
.string()
|
|
808
814
|
.optional()
|
|
809
815
|
.describe('MS Project start date override (YYYY-MM-DD).'),
|
|
810
|
-
|
|
811
|
-
.
|
|
816
|
+
delivery: z
|
|
817
|
+
.enum(['file', 'inline', 'both'])
|
|
812
818
|
.optional()
|
|
813
819
|
.describe(
|
|
814
|
-
'
|
|
820
|
+
'"file" — write to disk and return the path (no inline bytes). ' +
|
|
821
|
+
'"inline" — return bytes in the response (embedded resource for pdf/xlsx, image for png, text for other formats). ' +
|
|
822
|
+
'"both" — write to disk and also attach inline bytes. ' +
|
|
823
|
+
'Default is smart per format: pdf/xlsx/png write to disk when a root folder is configured, else return inline; text formats (svg/html/mermaid/msproj) always return inline. ' +
|
|
824
|
+
'IMPORTANT — for pdf/xlsx/png on Claude Desktop, prefer omitting `delivery` entirely (smart default saves the file). ' +
|
|
825
|
+
'Do NOT use delivery:"inline" for pdf/xlsx — Claude Desktop silently drops inline documents, so nothing appears to the user.',
|
|
815
826
|
),
|
|
816
827
|
}),
|
|
817
828
|
outputSchema: ExportOutputSchema,
|
|
@@ -851,50 +862,157 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
851
862
|
}
|
|
852
863
|
const host = createNodeHostEnv(filePath);
|
|
853
864
|
const bytes = await exportDocument(source, format, inputs, host);
|
|
854
|
-
const shareUrl = args.share
|
|
855
|
-
? (buildShareLink({ source, share: true }) ?? undefined)
|
|
856
|
-
: undefined;
|
|
857
865
|
|
|
858
866
|
const BINARY_FORMATS = new Set<ExportFormat>(['png', 'pdf', 'xlsx']);
|
|
859
867
|
const isBinary = BINARY_FORMATS.has(format);
|
|
860
868
|
|
|
861
|
-
|
|
869
|
+
// Default filename: <roadmap-id>.<ext>, used when no output path is given.
|
|
870
|
+
const FORMAT_EXT: Record<string, string> = {
|
|
871
|
+
svg: '.svg',
|
|
872
|
+
pdf: '.pdf',
|
|
873
|
+
xlsx: '.xlsx',
|
|
874
|
+
png: '.png',
|
|
875
|
+
html: '.html',
|
|
876
|
+
mermaid: '.md',
|
|
877
|
+
msproj: '.xml',
|
|
878
|
+
};
|
|
879
|
+
const ext = FORMAT_EXT[format] ?? `.${format}`;
|
|
880
|
+
const roadmapId =
|
|
881
|
+
(blocked.doc.parseResult.value as NowlineFile).roadmapDecl?.name ?? 'roadmap';
|
|
882
|
+
const defaultFilename = `${roadmapId}${ext}`;
|
|
883
|
+
|
|
884
|
+
// Resolve delivery mode.
|
|
885
|
+
const hasExplicitOutput = !!args.output;
|
|
886
|
+
const delivery = args.delivery;
|
|
887
|
+
let doWrite: boolean;
|
|
888
|
+
let doInline: boolean;
|
|
889
|
+
|
|
890
|
+
if (hasExplicitOutput) {
|
|
891
|
+
doWrite = true;
|
|
892
|
+
doInline = delivery === 'both';
|
|
893
|
+
} else if (delivery === 'file') {
|
|
894
|
+
doWrite = true;
|
|
895
|
+
doInline = false;
|
|
896
|
+
} else if (delivery === 'inline') {
|
|
897
|
+
doWrite = false;
|
|
898
|
+
doInline = true;
|
|
899
|
+
} else if (delivery === 'both') {
|
|
900
|
+
doWrite = true;
|
|
901
|
+
doInline = true;
|
|
902
|
+
} else {
|
|
903
|
+
// Smart default: binary formats (pdf/xlsx/png) write to the configured root
|
|
904
|
+
// (return a reference, not bytes — the idiomatic MCP pattern). Text formats
|
|
905
|
+
// (svg/html/mermaid/msproj) always return inline so Claude can surface them
|
|
906
|
+
// as downloadable artifacts.
|
|
907
|
+
if (format === 'pdf' || format === 'xlsx' || format === 'png') {
|
|
908
|
+
doWrite = rootConfigured;
|
|
909
|
+
doInline = !rootConfigured;
|
|
910
|
+
} else {
|
|
911
|
+
// svg/html/mermaid/msproj: always inline text.
|
|
912
|
+
doWrite = false;
|
|
913
|
+
doInline = true;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Write to disk if needed.
|
|
918
|
+
const outTarget = args.output ?? path.join(allowedRoot, defaultFilename);
|
|
919
|
+
let writtenPath: string | undefined;
|
|
920
|
+
if (doWrite) {
|
|
862
921
|
try {
|
|
863
|
-
const outAbs = resolveAndGuard(
|
|
922
|
+
const outAbs = resolveAndGuard(outTarget, allowedRoot);
|
|
864
923
|
await fs.mkdir(path.dirname(outAbs), { recursive: true });
|
|
865
924
|
await fs.writeFile(outAbs, bytes);
|
|
866
|
-
|
|
867
|
-
return {
|
|
868
|
-
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
869
|
-
structuredContent: structured,
|
|
870
|
-
};
|
|
925
|
+
writtenPath = outAbs;
|
|
871
926
|
} catch (err) {
|
|
872
|
-
return handleToolError(err, args.output);
|
|
927
|
+
return handleToolError(err, args.output ?? outTarget);
|
|
873
928
|
}
|
|
874
929
|
}
|
|
875
930
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
931
|
+
// Build response content.
|
|
932
|
+
const contentBlocks: ContentBlock[] = [];
|
|
933
|
+
if (writtenPath !== undefined) {
|
|
934
|
+
contentBlocks.push({
|
|
935
|
+
type: 'text',
|
|
936
|
+
text: `Saved to \`${writtenPath}\` (${bytes.byteLength} bytes).`,
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
if (doInline) {
|
|
940
|
+
if (isBinary) {
|
|
941
|
+
contentBlocks.push(
|
|
942
|
+
...buildExportInlineContentBlocks(format, bytes, !doWrite, rootConfigured),
|
|
943
|
+
);
|
|
944
|
+
} else {
|
|
945
|
+
const TEXT_MIME: Record<string, string> = {
|
|
946
|
+
svg: 'image/svg+xml',
|
|
947
|
+
html: 'text/html',
|
|
948
|
+
mermaid: 'text/markdown',
|
|
949
|
+
msproj: 'application/xml',
|
|
950
|
+
};
|
|
951
|
+
const text = new TextDecoder('utf-8').decode(bytes);
|
|
952
|
+
const mt = TEXT_MIME[format];
|
|
953
|
+
contentBlocks.push({ type: 'text', text, ...(mt ? { mimeType: mt } : {}) });
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const structured: Record<string, unknown> = { format };
|
|
958
|
+
if (writtenPath !== undefined) {
|
|
959
|
+
structured.path = writtenPath;
|
|
960
|
+
structured.bytes = bytes.byteLength;
|
|
961
|
+
} else if (doInline && isBinary) {
|
|
962
|
+
structured.bytes = bytes.byteLength;
|
|
893
963
|
}
|
|
894
|
-
|
|
895
|
-
const structured = { format, shareUrl };
|
|
964
|
+
|
|
896
965
|
return {
|
|
897
|
-
content:
|
|
966
|
+
content: contentBlocks,
|
|
967
|
+
structuredContent: structured,
|
|
968
|
+
};
|
|
969
|
+
},
|
|
970
|
+
);
|
|
971
|
+
|
|
972
|
+
// ---- share --------------------------------------------------------------
|
|
973
|
+
|
|
974
|
+
server.registerTool(
|
|
975
|
+
'share',
|
|
976
|
+
{
|
|
977
|
+
description:
|
|
978
|
+
'Generate a shareable link for a roadmap. The roadmap source is encoded into the URL fragment ' +
|
|
979
|
+
'(client-side; no upload, no account, no network call) and opens in the free Nowline web app ' +
|
|
980
|
+
'(free.nowline.io/open), where anyone with the link can view it and export to PDF/PNG/SVG. ' +
|
|
981
|
+
'Prefer `render` to show a roadmap in chat — that is the default. Use `share` only when the user ' +
|
|
982
|
+
'explicitly asks for a link to open, send, or export elsewhere.',
|
|
983
|
+
inputSchema: z.object({
|
|
984
|
+
source: z.string().optional().describe('Inline .nowline source text.'),
|
|
985
|
+
path: z
|
|
986
|
+
.string()
|
|
987
|
+
.optional()
|
|
988
|
+
.describe(
|
|
989
|
+
'Real local filesystem path to the .nowline file (e.g. /Users/name/Desktop/foo.nowline). ' +
|
|
990
|
+
'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
|
|
991
|
+
'those do not exist on the host filesystem. Pass `source` instead.',
|
|
992
|
+
),
|
|
993
|
+
}),
|
|
994
|
+
outputSchema: ShareOutputSchema,
|
|
995
|
+
annotations: readOnlyTool('Share Roadmap'),
|
|
996
|
+
},
|
|
997
|
+
async (args) => {
|
|
998
|
+
let source: string;
|
|
999
|
+
let filePath: string;
|
|
1000
|
+
try {
|
|
1001
|
+
({ source, filePath } = await sourceAndPath(args, allowedRoot));
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
return handleToolError(err, args.path);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const blocked = await diagnosticsErrorBlock(source, filePath);
|
|
1007
|
+
if (!blocked.ok) return blocked.response;
|
|
1008
|
+
|
|
1009
|
+
// buildShareLink returns null only for share:false/'none'; this tool
|
|
1010
|
+
// always requests share:true, so the result is always a link.
|
|
1011
|
+
const shareUrl = buildShareLink({ source, share: true }) as string;
|
|
1012
|
+
|
|
1013
|
+
const structured = { shareUrl };
|
|
1014
|
+
return {
|
|
1015
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
898
1016
|
structuredContent: structured,
|
|
899
1017
|
};
|
|
900
1018
|
},
|
|
@@ -1207,7 +1325,62 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
|
|
|
1207
1325
|
|
|
1208
1326
|
type ContentBlock =
|
|
1209
1327
|
| { type: 'text'; text: string; mimeType?: string }
|
|
1210
|
-
| { type: 'image'; data: string; mimeType: string }
|
|
1328
|
+
| { type: 'image'; data: string; mimeType: string }
|
|
1329
|
+
| {
|
|
1330
|
+
type: 'resource';
|
|
1331
|
+
resource: { uri: string; mimeType: string; blob: string };
|
|
1332
|
+
};
|
|
1333
|
+
|
|
1334
|
+
const EXPORT_BINARY_SHARE_HINT_NO_ROOT =
|
|
1335
|
+
'No file was written. To save a file, pass `output: "/path/to/roadmap.pdf"` or `delivery: "file"` ' +
|
|
1336
|
+
'(on Claude Desktop, configure an output folder in the server settings so the server can auto-save). ' +
|
|
1337
|
+
'For a shareable link the user can view and export from, call the `share` tool.';
|
|
1338
|
+
|
|
1339
|
+
const EXPORT_BINARY_SHARE_HINT_ROOT_CONFIGURED =
|
|
1340
|
+
'No file was written because `delivery:"inline"` was requested. ' +
|
|
1341
|
+
'Claude Desktop cannot display inline pdf/xlsx documents, so nothing appeared. ' +
|
|
1342
|
+
'Omit `delivery` (or use `delivery:"file"`) to let the server save the file to the configured output folder — ' +
|
|
1343
|
+
'then share that local path with the user. Alternatively call the `share` tool for an openable link.';
|
|
1344
|
+
|
|
1345
|
+
function buildExportInlineContentBlocks(
|
|
1346
|
+
format: ExportFormat,
|
|
1347
|
+
bytes: Uint8Array,
|
|
1348
|
+
showHint = true,
|
|
1349
|
+
rootConfigured = false,
|
|
1350
|
+
): ContentBlock[] {
|
|
1351
|
+
const mimeMap: Record<string, string> = {
|
|
1352
|
+
png: 'image/png',
|
|
1353
|
+
pdf: 'application/pdf',
|
|
1354
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
1355
|
+
};
|
|
1356
|
+
const mimeType = mimeMap[format] ?? 'application/octet-stream';
|
|
1357
|
+
const base64 = Buffer.from(bytes).toString('base64');
|
|
1358
|
+
|
|
1359
|
+
// PNG renders inline as an image — no resource wrapper or share hint needed.
|
|
1360
|
+
if (format === 'png') {
|
|
1361
|
+
return [{ type: 'image', data: base64, mimeType }];
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// pdf/xlsx: embedded resource block, and optionally a hint pointing the agent
|
|
1365
|
+
// at output:/delivery:'file' when no file was also written.
|
|
1366
|
+
const blocks: ContentBlock[] = [
|
|
1367
|
+
{
|
|
1368
|
+
type: 'resource',
|
|
1369
|
+
resource: {
|
|
1370
|
+
uri: `nowline://export/roadmap.${format}`,
|
|
1371
|
+
mimeType,
|
|
1372
|
+
blob: base64,
|
|
1373
|
+
},
|
|
1374
|
+
},
|
|
1375
|
+
];
|
|
1376
|
+
if (showHint) {
|
|
1377
|
+
const hint = rootConfigured
|
|
1378
|
+
? EXPORT_BINARY_SHARE_HINT_ROOT_CONFIGURED
|
|
1379
|
+
: EXPORT_BINARY_SHARE_HINT_NO_ROOT;
|
|
1380
|
+
blocks.push({ type: 'text', text: hint });
|
|
1381
|
+
}
|
|
1382
|
+
return blocks;
|
|
1383
|
+
}
|
|
1211
1384
|
|
|
1212
1385
|
async function buildReviewContentBlocks(
|
|
1213
1386
|
source: string,
|