@nowline/mcp 0.8.2 → 0.8.3

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/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
- const server = createMcpServer({ allowedRoot: root, version: PKG_VERSION });
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).
@@ -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
- shareUrl: z.string().optional(),
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+share). ' +
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, shareUrl, ...insightsField };
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
- 'Real local filesystem path to write the output (e.g. /Users/name/Desktop/roadmap.pdf). ' +
793
- 'Required for binary formats (pdf, xlsx, msproj, png). ' +
794
- 'Never pass a virtual or sandbox path such as /mnt/user-data/… — ' +
795
- 'those do not exist on the host filesystem.',
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
- share: z
811
- .boolean()
816
+ delivery: z
817
+ .enum(['file', 'inline', 'both'])
812
818
  .optional()
813
819
  .describe(
814
- 'When true, include a shareUrl pointing to https://free.nowline.io/open.',
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
- if (args.output) {
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(args.output, allowedRoot);
922
+ const outAbs = resolveAndGuard(outTarget, allowedRoot);
864
923
  await fs.mkdir(path.dirname(outAbs), { recursive: true });
865
924
  await fs.writeFile(outAbs, bytes);
866
- const structured = { format, path: outAbs, bytes: bytes.byteLength, shareUrl };
867
- return {
868
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
869
- structuredContent: structured,
870
- };
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
- if (isBinary) {
877
- const mimeMap: Record<string, string> = {
878
- png: 'image/png',
879
- pdf: 'application/pdf',
880
- xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
881
- };
882
- const structured = { format, bytes: bytes.byteLength, shareUrl };
883
- return {
884
- content: [
885
- {
886
- type: 'image',
887
- data: Buffer.from(bytes).toString('base64'),
888
- mimeType: mimeMap[format] ?? 'application/octet-stream',
889
- },
890
- ],
891
- structuredContent: structured,
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
- const text = new TextDecoder('utf-8').decode(bytes);
895
- const structured = { format, shareUrl };
964
+
896
965
  return {
897
- content: [{ type: 'text', text }],
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,