jotview 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -1
- package/package.json +5 -2
- package/src/cli.js +21 -5
- package/src/codec.js +1 -1
- package/src/files.js +87 -0
- package/src/mcp-tool.js +61 -10
- package/src/mcp.js +2 -1
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
|
|
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,24 @@ 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
|
+
|
|
196
|
+
If a release run fails partway, fix the cause and finish it with
|
|
197
|
+
`gh workflow run release.yml -f tag=vX.Y.Z` — finished steps are skipped.
|
|
198
|
+
|
|
154
199
|
## License
|
|
155
200
|
|
|
156
201
|
MIT
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jotview",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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
|
-
|
|
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
|
|
@@ -15,7 +17,8 @@ const DEFINITION = {
|
|
|
15
17
|
name: "render_markdown",
|
|
16
18
|
title: "Render markdown with jotbins",
|
|
17
19
|
description:
|
|
18
|
-
"Turn markdown (GitHub-flavored, with ```mermaid diagrams
|
|
20
|
+
"Turn markdown (GitHub-flavored, with ```mermaid diagrams, $math$ / $$math$$, " +
|
|
21
|
+
"> [!NOTE] callouts and :emoji: shortcodes) into a link that " +
|
|
19
22
|
"renders it as a page on jotbins.com. Returns a ready-made markdown link to " +
|
|
20
23
|
"show the user. Small documents live entirely in the link; larger ones are " +
|
|
21
24
|
"uploaded to surl.jotbins.com for a short link that expires after 30 days " +
|
|
@@ -46,13 +49,40 @@ const DEFINITION = {
|
|
|
46
49
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
47
50
|
};
|
|
48
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The definition, plus a `path` input when the server may read files.
|
|
54
|
+
* @param {string[]} roots
|
|
55
|
+
*/
|
|
56
|
+
function definitionFor(roots) {
|
|
57
|
+
if (roots.length === 0) return DEFINITION;
|
|
58
|
+
const { required: _required, ...schema } = DEFINITION.inputSchema;
|
|
59
|
+
return {
|
|
60
|
+
...DEFINITION,
|
|
61
|
+
inputSchema: {
|
|
62
|
+
...schema,
|
|
63
|
+
properties: {
|
|
64
|
+
...schema.properties,
|
|
65
|
+
content: { type: "string", description: "The markdown to render. Pass this or path." },
|
|
66
|
+
path: {
|
|
67
|
+
type: "string",
|
|
68
|
+
description:
|
|
69
|
+
`A ${READABLE_EXTENSIONS.join("/")} file to render instead of content — saves ` +
|
|
70
|
+
`sending the text. Relative to ${roots[0]}; only files under ` +
|
|
71
|
+
`${roots.join(", ")} can be read.`,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
49
78
|
/**
|
|
50
79
|
* The first markdown heading, as safe link text.
|
|
51
80
|
* @param {string} md
|
|
81
|
+
* @param {string} [fallback] used when there is no heading
|
|
52
82
|
*/
|
|
53
|
-
export function docTitle(md) {
|
|
83
|
+
export function docTitle(md, fallback = "jotbins doc") {
|
|
54
84
|
const match = md.match(/^#{1,6}[ \t]+(.+?)[ \t#]*$/m);
|
|
55
|
-
return linkText(match ? match[1] :
|
|
85
|
+
return linkText(match ? match[1] : fallback);
|
|
56
86
|
}
|
|
57
87
|
|
|
58
88
|
/** @param {string} text */
|
|
@@ -66,8 +96,13 @@ const toolError = (text) => ({ content: [{ type: "text", text }], isError: true
|
|
|
66
96
|
* @returns {string|null} what's wrong, phrased for the model to fix
|
|
67
97
|
*/
|
|
68
98
|
function validate(args) {
|
|
69
|
-
if (
|
|
70
|
-
|
|
99
|
+
if (args.path !== undefined) {
|
|
100
|
+
if (args.content !== undefined) return "pass content or path, not both.";
|
|
101
|
+
if (typeof args.path !== "string" || args.path.trim() === "") return "path must be a file path.";
|
|
102
|
+
} else {
|
|
103
|
+
if (typeof args.content !== "string") return "content is required: pass the markdown to render as a string.";
|
|
104
|
+
if (args.content.trim() === "") return "content is empty: pass the markdown to render.";
|
|
105
|
+
}
|
|
71
106
|
if (args.keep !== undefined && typeof args.keep !== "boolean") return "keep must be true or false.";
|
|
72
107
|
if (args.title !== undefined && (typeof args.title !== "string" || args.title.trim() === "")) {
|
|
73
108
|
return "title must be a non-empty string, or left out to use the first heading.";
|
|
@@ -110,17 +145,31 @@ function describe(r, keepAsked) {
|
|
|
110
145
|
* allowUpload?: boolean,
|
|
111
146
|
* baseUrl?: string,
|
|
112
147
|
* shortenImpl?: typeof shorten,
|
|
113
|
-
*
|
|
148
|
+
* roots?: string[],
|
|
149
|
+
* }} opts `allowUpload: false` is `jotview mcp --raw`; `roots` (from
|
|
150
|
+
* allowedRoots) enables the `path` input
|
|
114
151
|
* @returns {import("./mcp.js").Tool}
|
|
115
152
|
*/
|
|
116
153
|
export function renderTool(opts) {
|
|
154
|
+
const roots = opts.roots ?? [];
|
|
117
155
|
return {
|
|
118
|
-
definition:
|
|
156
|
+
definition: definitionFor(roots),
|
|
119
157
|
async call(args) {
|
|
120
158
|
const problem = validate(args);
|
|
121
159
|
if (problem) return toolError(problem);
|
|
122
160
|
|
|
123
|
-
const
|
|
161
|
+
const path = /** @type {string|undefined} */ (args.path);
|
|
162
|
+
let md;
|
|
163
|
+
if (path === undefined) {
|
|
164
|
+
md = /** @type {string} */ (args.content);
|
|
165
|
+
} else {
|
|
166
|
+
try {
|
|
167
|
+
md = readAllowedFile(path, roots);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (err instanceof JotviewError) return toolError(`Can't read that file: ${err.message}.`);
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
124
173
|
const keep = args.keep === true;
|
|
125
174
|
let r;
|
|
126
175
|
try {
|
|
@@ -133,12 +182,14 @@ export function renderTool(opts) {
|
|
|
133
182
|
shortenImpl: opts.shortenImpl ?? shorten,
|
|
134
183
|
});
|
|
135
184
|
} catch (err) {
|
|
136
|
-
if (err instanceof JotviewError)
|
|
185
|
+
if (err instanceof JotviewError) {
|
|
186
|
+
return toolError(err.code === "too-large" ? `${err.message}. Shorten or split the document.` : `${err.message}.`);
|
|
187
|
+
}
|
|
137
188
|
throw err;
|
|
138
189
|
}
|
|
139
190
|
|
|
140
191
|
const text =
|
|
141
|
-
`[${typeof args.title === "string" ? linkText(args.title) : docTitle(md)}](${r.url})\n\n` +
|
|
192
|
+
`[${typeof args.title === "string" ? linkText(args.title) : docTitle(md, path && basename(path))}](${r.url})\n\n` +
|
|
142
193
|
`${describe(r, keep && !r.shortened)}\n` +
|
|
143
194
|
"Show it to the user as the markdown link above, not as a bare URL.";
|
|
144
195
|
return { content: [{ type: "text", text }] };
|
package/src/mcp.js
CHANGED
|
@@ -12,7 +12,8 @@ export const LEGACY_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-
|
|
|
12
12
|
export const MODERN_VERSIONS = ["2026-07-28"];
|
|
13
13
|
|
|
14
14
|
export const INSTRUCTIONS =
|
|
15
|
-
"Use render_markdown to share markdown — including Mermaid diagrams
|
|
15
|
+
"Use render_markdown to share markdown — including Mermaid diagrams, $…$ " +
|
|
16
|
+
"math and > [!NOTE] / [!TIP] / [!WARNING] callouts — as a " +
|
|
16
17
|
"rendered jotbins page. Show the returned link to the user as a markdown " +
|
|
17
18
|
"link, [title](url); never paste a bare jotbins.com/d URL, it can be " +
|
|
18
19
|
"thousands of characters. Long documents are uploaded to shorten the link " +
|