jotview 0.4.1 → 0.5.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/README.md CHANGED
@@ -29,6 +29,7 @@ jotview mcp [--raw] run as an MCP server for AI agents
29
29
  --keep make a shortened link permanent
30
30
  --open open the link in your browser
31
31
  --copy copy the link to your clipboard
32
+ --json print the result as one JSON line (see below)
32
33
  --base-url <url> override https://jotbins.com
33
34
  -h, --help
34
35
  -v, --version
@@ -89,7 +90,11 @@ it reads a jotbins link you share.
89
90
  claude mcp add jotview -- npx -y jotview mcp
90
91
  ```
91
92
 
92
- **Claude Desktop, Cursor, and other clients** — add to the MCP config:
93
+ **Claude Desktop** — download `jotview.mcpb` from the
94
+ [latest release](https://github.com/jotbins/jotview/releases/latest) and
95
+ double-click it. Optionally pick a docs folder it may read files from.
96
+
97
+ **Cursor and other clients** — add to the MCP config:
93
98
 
94
99
  ```json
95
100
  { "mcpServers": { "jotview": { "command": "npx", "args": ["-y", "jotview", "mcp"] } } }
@@ -104,6 +109,19 @@ ready-made markdown link, `[First heading](url)`, and a note on whether
104
109
  anything was uploaded. It never reads files — the AI passes text it already
105
110
  has.
106
111
 
112
+ **Rendering files.** The local server can also take a `path` instead of
113
+ `content`, so the AI doesn't have to paste a whole file into the tool call.
114
+ It reads only `.md`, `.markdown`, `.mdx` and `.txt` files, and only inside
115
+ the folder the server runs in (Claude Code starts it in your project) or a
116
+ folder you allow with `--allow-dir`:
117
+
118
+ ```bash
119
+ claude mcp add -s user jotview -- npx -y jotview mcp --allow-dir ~/notes
120
+ ```
121
+
122
+ If the server runs in `/` or your home folder and no `--allow-dir` is given,
123
+ `path` is switched off. The remote server never reads files.
124
+
107
125
  **`read_link`** takes a `url` (a `/d` link or a short link) and returns the
108
126
  markdown behind it, so you can paste a link and say *"fix the typos and give
109
127
  me a new link."* Links never change; an edit is a new link.
@@ -141,6 +159,15 @@ The Worker lives in [`worker/`](worker/) and deploys with
141
159
  **stdout is the URL and nothing else.** Notices, warnings, and errors go to
142
160
  stderr, so `$(jotview x.md)` and `jotview x.md | pbcopy` stay clean.
143
161
 
162
+ With `--json`, stdout is one JSON line instead, for scripts and CI:
163
+
164
+ ```json
165
+ {"url":"https://surl.jotbins.com/abc123","rawUrl":"https://jotbins.com/d#z:…","shortened":true,"permanent":false,"failure":null}
166
+ ```
167
+
168
+ `failure` is the shortener's error when shortening was tried and failed
169
+ (`url` is then the full link).
170
+
144
171
  Exit codes: `0` success, `1` runtime error, `2` usage error.
145
172
 
146
173
  ## Limits
@@ -151,6 +178,21 @@ Input is capped at 1 MB, matching what the `/d` renderer will open.
151
178
 
152
179
  Node 18 or newer.
153
180
 
181
+ ## Releasing
182
+
183
+ Releases run from GitHub Actions. On an up-to-date `main`:
184
+
185
+ ```bash
186
+ npm version minor
187
+ git push --follow-tags
188
+ ```
189
+
190
+ `npm version` also updates `manifest.json` and `server.json`. The `v*` tag
191
+ then tests, publishes to npm (trusted publishing, with provenance), attaches
192
+ `jotview.mcpb` to a GitHub release, and updates the MCP Registry entry. Move
193
+ the `Unreleased` notes in `CHANGELOG.md` under the new version first (commit that before `npm version`). The
194
+ Worker deploys separately: `cd worker && npx wrangler deploy`.
195
+
154
196
  ## License
155
197
 
156
198
  MIT
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "jotview",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Turn markdown into a jotbins /d link — a CLI and an MCP server. Zero dependencies.",
5
+ "mcpName": "io.github.jotbins/jotview",
5
6
  "type": "module",
6
7
  "bin": {
7
8
  "jotview": "bin/jotview.js"
@@ -16,7 +17,9 @@
16
17
  "node": ">=18"
17
18
  },
18
19
  "scripts": {
19
- "test": "node --test"
20
+ "test": "node --test",
21
+ "version": "node scripts/sync-version.js && git add manifest.json server.json",
22
+ "bundle": "npx -y @anthropic-ai/mcpb pack . jotview.mcpb"
20
23
  },
21
24
  "keywords": [
22
25
  "markdown",
package/src/cli.js CHANGED
@@ -3,6 +3,7 @@ import { JotviewError, MAX_DOC_BYTES } from "./codec.js";
3
3
  import { DEFAULT_BASE_URL, SHORTENER_URL } from "./shorten.js";
4
4
  import { mint } from "./mint.js";
5
5
  import { readLink } from "./read.js";
6
+ import { allowedRoots } from "./files.js";
6
7
  import { serve } from "./mcp-stdio.js";
7
8
  import { renderTool } from "./mcp-tool.js";
8
9
  import { readTool } from "./mcp-read-tool.js";
@@ -20,7 +21,8 @@ Usage:
20
21
  jotview read markdown from stdin
21
22
  jotview decode <url> print the markdown behind a /d or short link
22
23
  jotview mcp [--raw] run as an MCP server (stdio) for AI agents;
23
- --raw never uploads, --base-url works too
24
+ --raw never uploads, --base-url works too,
25
+ --allow-dir <dir> lets it read files there
24
26
 
25
27
  Options:
26
28
  --raw never shorten; always the full /d URL
@@ -28,6 +30,7 @@ Options:
28
30
  --keep make a shortened link permanent
29
31
  --open open the link in your browser
30
32
  --copy copy the link to your clipboard
33
+ --json print {url, rawUrl, shortened, permanent, failure}
31
34
  --base-url <url> override ${DEFAULT_BASE_URL}
32
35
  -h, --help show this help
33
36
  -v, --version show the version
@@ -48,6 +51,7 @@ saved from the link's waiting page. Full /d links never expire.
48
51
  * @property {boolean} keep
49
52
  * @property {boolean} open
50
53
  * @property {boolean} copy
54
+ * @property {boolean} json
51
55
  * @property {string} baseUrl
52
56
  * @property {boolean} help
53
57
  * @property {boolean} version
@@ -60,7 +64,7 @@ saved from the link's waiting page. Full /d links never expire.
60
64
  export function parseArgs(argv) {
61
65
  /** @type {Options} */
62
66
  const opts = {
63
- file: null, raw: false, short: false, keep: false, open: false, copy: false,
67
+ file: null, raw: false, short: false, keep: false, open: false, copy: false, json: false,
64
68
  baseUrl: DEFAULT_BASE_URL, help: false, version: false,
65
69
  };
66
70
  for (let i = 0; i < argv.length; i++) {
@@ -71,6 +75,7 @@ export function parseArgs(argv) {
71
75
  case "--keep": opts.keep = true; break;
72
76
  case "--open": opts.open = true; break;
73
77
  case "--copy": opts.copy = true; break;
78
+ case "--json": opts.json = true; break;
74
79
  case "-h": case "--help": opts.help = true; break;
75
80
  case "-v": case "--version": opts.version = true; break;
76
81
  case "--base-url": {
@@ -110,11 +115,15 @@ export function parseArgs(argv) {
110
115
  * @returns {{raw: boolean, baseUrl: string}}
111
116
  */
112
117
  export function parseMcpArgs(argv) {
113
- const opts = { raw: false, baseUrl: DEFAULT_BASE_URL };
118
+ const opts = { raw: false, baseUrl: DEFAULT_BASE_URL, allowDirs: /** @type {string[]} */ ([]) };
114
119
  for (let i = 0; i < argv.length; i++) {
115
120
  const arg = argv[i];
116
121
  if (arg === "--raw") {
117
122
  opts.raw = true;
123
+ } else if (arg === "--allow-dir") {
124
+ const value = argv[++i];
125
+ if (value === undefined) throw new JotviewError("usage", "--allow-dir needs a folder");
126
+ opts.allowDirs.push(value);
118
127
  } else if (arg === "--base-url") {
119
128
  const value = argv[++i];
120
129
  if (value === undefined || value.startsWith("-") || !/^https?:\/\//.test(value)) {
@@ -245,7 +254,12 @@ export async function run(argv, io) {
245
254
  `link. the document may still have reached ${SHORTENER_HOST}.\n`,
246
255
  );
247
256
  }
248
- io.stdout(`${printed}\n`);
257
+ if (opts.json) {
258
+ const { url, rawUrl, shortened, permanent, failure } = minted;
259
+ io.stdout(`${JSON.stringify({ url, rawUrl, shortened, permanent, failure })}\n`);
260
+ } else {
261
+ io.stdout(`${printed}\n`);
262
+ }
249
263
  applySideEffects(printed, opts, io);
250
264
  return 0;
251
265
  } catch (err) {
@@ -285,8 +299,10 @@ async function runDecode(argv, io) {
285
299
  */
286
300
  async function runMcp(argv, io) {
287
301
  let opts;
302
+ let roots;
288
303
  try {
289
304
  opts = parseMcpArgs(argv);
305
+ roots = allowedRoots({ cwd: process.cwd(), extra: opts.allowDirs });
290
306
  } catch (err) {
291
307
  io.stderr(`jotview: ${/** @type {Error} */ (err).message}\n\n${HELP}`);
292
308
  return 2;
@@ -296,7 +312,7 @@ async function runMcp(argv, io) {
296
312
  write: io.stdout,
297
313
  ctx: {
298
314
  version: io.version,
299
- tools: [renderTool({ allowUpload: !opts.raw, baseUrl: opts.baseUrl }), readTool({})],
315
+ tools: [renderTool({ allowUpload: !opts.raw, baseUrl: opts.baseUrl, roots }), readTool({})],
300
316
  },
301
317
  });
302
318
  return 0;
package/src/codec.js CHANGED
@@ -7,7 +7,7 @@ export const PREFIX = "z:";
7
7
  * the browser refuses to open, so reject at encode time instead. */
8
8
  export const MAX_DOC_BYTES = 1_000_000;
9
9
 
10
- /** @typedef {"too-large" | "empty" | "not-found" | "usage" | "read-failed" | "bad-link" | "cleaned-up"} ErrorCode */
10
+ /** @typedef {"too-large" | "empty" | "not-found" | "usage" | "read-failed" | "bad-link" | "cleaned-up" | "not-allowed"} ErrorCode */
11
11
 
12
12
  export class JotviewError extends Error {
13
13
  /** @param {ErrorCode} code @param {string} message */
package/src/files.js ADDED
@@ -0,0 +1,87 @@
1
+ import { readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { extname, isAbsolute, parse, resolve, sep } from "node:path";
4
+ import { JotviewError, MAX_DOC_BYTES } from "./codec.js";
5
+
6
+ /** The only files the MCP `path` input reads: a prompt-injected model must
7
+ * not be able to render (and upload) keys, .env files and the like. */
8
+ export const READABLE_EXTENSIONS = [".md", ".markdown", ".mdx", ".txt"];
9
+
10
+ /** @param {string} p */
11
+ const isFilesystemRoot = (p) => parse(p).root === p;
12
+
13
+ /** @param {string} dir */
14
+ function realDir(dir) {
15
+ try {
16
+ const real = realpathSync(dir);
17
+ if (statSync(real).isDirectory()) return real;
18
+ } catch {
19
+ // fall through
20
+ }
21
+ throw new JotviewError("usage", `--allow-dir: not a folder: ${dir}`);
22
+ }
23
+
24
+ /**
25
+ * Folders the MCP server may read from: its working folder (Claude Code
26
+ * starts it in the project) unless that is `/` or the home folder, plus any
27
+ * `--allow-dir`. Blank values and unfilled `${…}` placeholders (an unset
28
+ * Desktop bundle setting) are ignored.
29
+ *
30
+ * @param {{cwd: string, extra?: string[], home?: string}} opts
31
+ * @returns {string[]} real paths, deduplicated
32
+ */
33
+ export function allowedRoots({ cwd, extra = [], home = homedir() }) {
34
+ const roots = [];
35
+ const realCwd = realpathSync(cwd);
36
+ let realHome = home;
37
+ try { realHome = realpathSync(home); } catch { /* keep as given */ }
38
+ if (!isFilesystemRoot(realCwd) && realCwd !== realHome) roots.push(realCwd);
39
+ for (const dir of extra) {
40
+ if (dir.trim() === "" || dir.startsWith("${")) continue;
41
+ roots.push(realDir(resolve(cwd, dir)));
42
+ }
43
+ return [...new Set(roots)];
44
+ }
45
+
46
+ /** @param {string} p */
47
+ const readable = (p) => READABLE_EXTENSIONS.includes(extname(p).toLowerCase());
48
+
49
+ /**
50
+ * Read a markdown or text file inside one of `roots`. Relative paths resolve
51
+ * against the first root. Symlinks are resolved before the checks.
52
+ *
53
+ * @param {string} path
54
+ * @param {string[]} roots from allowedRoots
55
+ * @returns {string}
56
+ */
57
+ export function readAllowedFile(path, roots) {
58
+ if (roots.length === 0) {
59
+ throw new JotviewError("not-allowed", "file access is off for this server; pass the markdown as content");
60
+ }
61
+ const target = isAbsolute(path) ? path : resolve(roots[0], path);
62
+ const allowedTypes = READABLE_EXTENSIONS.join(" ");
63
+ if (!readable(target)) {
64
+ throw new JotviewError("not-allowed", `only ${allowedTypes} files can be read`);
65
+ }
66
+
67
+ let real;
68
+ try {
69
+ real = realpathSync(target);
70
+ } catch {
71
+ throw new JotviewError("not-found", `no such file: ${path}`);
72
+ }
73
+ const inside = roots.some((r) => real.startsWith(r.endsWith(sep) ? r : r + sep));
74
+ if (!inside) {
75
+ throw new JotviewError("not-allowed", `${path} is outside the folders this server may read (${roots.join(", ")})`);
76
+ }
77
+ if (!readable(real)) {
78
+ throw new JotviewError("not-allowed", `only ${allowedTypes} files can be read`);
79
+ }
80
+
81
+ const stat = statSync(real);
82
+ if (!stat.isFile()) throw new JotviewError("not-found", `not a file: ${path}`);
83
+ if (stat.size > MAX_DOC_BYTES) {
84
+ throw new JotviewError("too-large", `${path} is ${stat.size} bytes; the limit is ${MAX_DOC_BYTES}`);
85
+ }
86
+ return readFileSync(real, "utf8");
87
+ }
package/src/mcp-tool.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { mint } from "./mint.js";
2
2
  import { shorten, DEFAULT_BASE_URL, SHORTENER_URL } from "./shorten.js";
3
3
  import { JotviewError } from "./codec.js";
4
+ import { readAllowedFile, READABLE_EXTENSIONS } from "./files.js";
5
+ import { basename } from "node:path";
4
6
 
5
7
  /** Every character of a /d link costs the user tokens twice — once handed
6
8
  * back to the model, once written into its reply — so MCP shortens much
@@ -46,13 +48,40 @@ const DEFINITION = {
46
48
  annotations: { readOnlyHint: true, openWorldHint: true },
47
49
  };
48
50
 
51
+ /**
52
+ * The definition, plus a `path` input when the server may read files.
53
+ * @param {string[]} roots
54
+ */
55
+ function definitionFor(roots) {
56
+ if (roots.length === 0) return DEFINITION;
57
+ const { required: _required, ...schema } = DEFINITION.inputSchema;
58
+ return {
59
+ ...DEFINITION,
60
+ inputSchema: {
61
+ ...schema,
62
+ properties: {
63
+ ...schema.properties,
64
+ content: { type: "string", description: "The markdown to render. Pass this or path." },
65
+ path: {
66
+ type: "string",
67
+ description:
68
+ `A ${READABLE_EXTENSIONS.join("/")} file to render instead of content — saves ` +
69
+ `sending the text. Relative to ${roots[0]}; only files under ` +
70
+ `${roots.join(", ")} can be read.`,
71
+ },
72
+ },
73
+ },
74
+ };
75
+ }
76
+
49
77
  /**
50
78
  * The first markdown heading, as safe link text.
51
79
  * @param {string} md
80
+ * @param {string} [fallback] used when there is no heading
52
81
  */
53
- export function docTitle(md) {
82
+ export function docTitle(md, fallback = "jotbins doc") {
54
83
  const match = md.match(/^#{1,6}[ \t]+(.+?)[ \t#]*$/m);
55
- return linkText(match ? match[1] : "jotbins doc");
84
+ return linkText(match ? match[1] : fallback);
56
85
  }
57
86
 
58
87
  /** @param {string} text */
@@ -66,8 +95,13 @@ const toolError = (text) => ({ content: [{ type: "text", text }], isError: true
66
95
  * @returns {string|null} what's wrong, phrased for the model to fix
67
96
  */
68
97
  function validate(args) {
69
- if (typeof args.content !== "string") return "content is required: pass the markdown to render as a string.";
70
- if (args.content.trim() === "") return "content is empty: pass the markdown to render.";
98
+ if (args.path !== undefined) {
99
+ if (args.content !== undefined) return "pass content or path, not both.";
100
+ if (typeof args.path !== "string" || args.path.trim() === "") return "path must be a file path.";
101
+ } else {
102
+ if (typeof args.content !== "string") return "content is required: pass the markdown to render as a string.";
103
+ if (args.content.trim() === "") return "content is empty: pass the markdown to render.";
104
+ }
71
105
  if (args.keep !== undefined && typeof args.keep !== "boolean") return "keep must be true or false.";
72
106
  if (args.title !== undefined && (typeof args.title !== "string" || args.title.trim() === "")) {
73
107
  return "title must be a non-empty string, or left out to use the first heading.";
@@ -110,17 +144,31 @@ function describe(r, keepAsked) {
110
144
  * allowUpload?: boolean,
111
145
  * baseUrl?: string,
112
146
  * shortenImpl?: typeof shorten,
113
- * }} opts `allowUpload: false` is `jotview mcp --raw`
147
+ * roots?: string[],
148
+ * }} opts `allowUpload: false` is `jotview mcp --raw`; `roots` (from
149
+ * allowedRoots) enables the `path` input
114
150
  * @returns {import("./mcp.js").Tool}
115
151
  */
116
152
  export function renderTool(opts) {
153
+ const roots = opts.roots ?? [];
117
154
  return {
118
- definition: DEFINITION,
155
+ definition: definitionFor(roots),
119
156
  async call(args) {
120
157
  const problem = validate(args);
121
158
  if (problem) return toolError(problem);
122
159
 
123
- const md = /** @type {string} */ (args.content);
160
+ const path = /** @type {string|undefined} */ (args.path);
161
+ let md;
162
+ if (path === undefined) {
163
+ md = /** @type {string} */ (args.content);
164
+ } else {
165
+ try {
166
+ md = readAllowedFile(path, roots);
167
+ } catch (err) {
168
+ if (err instanceof JotviewError) return toolError(`Can't read that file: ${err.message}.`);
169
+ throw err;
170
+ }
171
+ }
124
172
  const keep = args.keep === true;
125
173
  let r;
126
174
  try {
@@ -133,12 +181,14 @@ export function renderTool(opts) {
133
181
  shortenImpl: opts.shortenImpl ?? shorten,
134
182
  });
135
183
  } catch (err) {
136
- if (err instanceof JotviewError) return toolError(`${err.message}. Shorten or split the document.`);
184
+ if (err instanceof JotviewError) {
185
+ return toolError(err.code === "too-large" ? `${err.message}. Shorten or split the document.` : `${err.message}.`);
186
+ }
137
187
  throw err;
138
188
  }
139
189
 
140
190
  const text =
141
- `[${typeof args.title === "string" ? linkText(args.title) : docTitle(md)}](${r.url})\n\n` +
191
+ `[${typeof args.title === "string" ? linkText(args.title) : docTitle(md, path && basename(path))}](${r.url})\n\n` +
142
192
  `${describe(r, keep && !r.shortened)}\n` +
143
193
  "Show it to the user as the markdown link above, not as a bare URL.";
144
194
  return { content: [{ type: "text", text }] };