jotview 0.1.0 → 0.3.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
@@ -21,9 +21,11 @@ entirely in the browser.
21
21
  ```
22
22
  jotview <file> mint a link from a file
23
23
  jotview read markdown from stdin
24
+ jotview mcp [--raw] run as an MCP server for AI agents
24
25
 
25
26
  --raw never shorten; always the full /d URL
26
27
  --short always shorten, even when small
28
+ --keep make a shortened link permanent
27
29
  --open open the link in your browser
28
30
  --copy copy the link to your clipboard
29
31
  --base-url <url> override https://jotbins.com
@@ -51,6 +53,7 @@ jotview tells you on stderr when that happens:
51
53
 
52
54
  ```
53
55
  note: uploaded to jotbins to shorten (8768 → 30 chars). use --raw to keep it local.
56
+ note: short link expires after 30 days unused. use --keep to keep it forever.
54
57
  ```
55
58
 
56
59
  **Shortening uploads your document.** The fragment *is* the markdown, so storing
@@ -58,9 +61,52 @@ the URL stores the text. Below the threshold nothing is uploaded and no network
58
61
  request is made at all. Use `--raw` to keep a long document local, accepting a
59
62
  URL too long to paste into most places.
60
63
 
64
+ **Short links clean up after themselves.** One nobody opens for 30 days is
65
+ deleted, with a 60-day window to bring it back from its waiting page. Pass
66
+ `--keep` to make it permanent from the start, or click **Save link** on the
67
+ waiting page any time before then. Full `/d` links are never stored, so they
68
+ never expire.
69
+
61
70
  If the shortener is unreachable or rate-limited, jotview prints the full link
62
71
  and exits 0 — it never fails your pipeline over a shortening problem.
63
72
 
73
+ ## Use with AI agents (MCP)
74
+
75
+ `jotview mcp` is a stdio [MCP](https://modelcontextprotocol.io) server with one
76
+ tool, `render_markdown`. An AI writes markdown — notes, a report, a Mermaid
77
+ diagram — and gets back a jotbins link to hand you.
78
+
79
+ **Claude Code**
80
+
81
+ ```bash
82
+ claude mcp add jotview -- npx -y jotview mcp
83
+ ```
84
+
85
+ **Claude Desktop, Cursor, and other clients** — add to the MCP config:
86
+
87
+ ```json
88
+ { "mcpServers": { "jotview": { "command": "npx", "args": ["-y", "jotview", "mcp"] } } }
89
+ ```
90
+
91
+ Then just ask: *"draw a diagram of our deploy flow and give me a jotbins link."*
92
+
93
+ **The tool** takes `content` (the markdown), plus optional `keep` (make a
94
+ short link permanent) and `shorten` (`"auto"`, `"always"` or `"never"`). It
95
+ returns a ready-made markdown link, `[First heading](url)`, and a note on
96
+ whether anything was uploaded. It never reads files — the AI passes text it
97
+ already has.
98
+
99
+ **Uploads.** Every character of a link costs tokens, so MCP shortens sooner
100
+ than the CLI: links over **500** characters go through `surl.jotbins.com`,
101
+ which uploads the document (short links expire after 30 days unused unless
102
+ `keep` is set). To forbid uploads entirely, start the server with `--raw`:
103
+
104
+ ```bash
105
+ claude mcp add jotview -- npx -y jotview mcp --raw
106
+ ```
107
+
108
+ Works with clients on any MCP revision from `2024-11-05` through `2026-07-28`.
109
+
64
110
  ## Output contract
65
111
 
66
112
  **stdout is the URL and nothing else.** Notices, warnings, and errors go to
package/package.json CHANGED
@@ -1,15 +1,39 @@
1
1
  {
2
2
  "name": "jotview",
3
- "version": "0.1.0",
4
- "description": "Turn a markdown file into a jotbins /d link. Zero dependencies.",
3
+ "version": "0.3.0",
4
+ "description": "Turn markdown into a jotbins /d link — a CLI and an MCP server. Zero dependencies.",
5
5
  "type": "module",
6
- "bin": { "jotview": "bin/jotview.js" },
7
- "files": ["bin", "src", "vectors.json", "README.md"],
8
- "engines": { "node": ">=18" },
9
- "scripts": { "test": "node --test" },
10
- "keywords": ["markdown", "jotbins", "cli", "share"],
6
+ "bin": {
7
+ "jotview": "bin/jotview.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "vectors.json",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "scripts": {
19
+ "test": "node --test"
20
+ },
21
+ "keywords": [
22
+ "markdown",
23
+ "jotbins",
24
+ "cli",
25
+ "share",
26
+ "mcp",
27
+ "mcp-server",
28
+ "mermaid"
29
+ ],
11
30
  "license": "MIT",
12
- "repository": { "type": "git", "url": "git+https://github.com/jotbins/jotview.git" },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/jotbins/jotview.git"
34
+ },
13
35
  "homepage": "https://github.com/jotbins/jotview#readme",
14
- "bugs": { "url": "https://github.com/jotbins/jotview/issues" }
36
+ "bugs": {
37
+ "url": "https://github.com/jotbins/jotview/issues"
38
+ }
15
39
  }
package/src/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { encode, JotviewError, MAX_DOC_BYTES } from "./codec.js";
3
- import {
4
- shorten, shouldShorten, DEFAULT_BASE_URL, SHORTEN_THRESHOLD, SHORTENER_URL,
5
- } from "./shorten.js";
2
+ import { JotviewError, MAX_DOC_BYTES } from "./codec.js";
3
+ import { DEFAULT_BASE_URL, SHORTENER_URL } from "./shorten.js";
4
+ import { mint } from "./mint.js";
5
+ import { serve } from "./mcp-stdio.js";
6
+ import { renderTool } from "./mcp-tool.js";
6
7
  import { openUrl, copyText } from "./platform.js";
7
8
  export { DEFAULT_BASE_URL };
8
9
 
@@ -15,10 +16,13 @@ export const HELP = `jotview — turn markdown into a jotbins /d link
15
16
  Usage:
16
17
  jotview <file> mint a link from a file
17
18
  jotview read markdown from stdin
19
+ jotview mcp [--raw] run as an MCP server (stdio) for AI agents;
20
+ --raw never uploads, --base-url works too
18
21
 
19
22
  Options:
20
23
  --raw never shorten; always the full /d URL
21
24
  --short always shorten, even when small
25
+ --keep make a shortened link permanent
22
26
  --open open the link in your browser
23
27
  --copy copy the link to your clipboard
24
28
  --base-url <url> override ${DEFAULT_BASE_URL}
@@ -28,6 +32,9 @@ Options:
28
32
  Links 2000 characters or fewer are printed as-is and never leave your machine.
29
33
  Longer ones are shortened via surl.jotbins.com, which uploads the document.
30
34
  Use --raw to keep it local regardless of size.
35
+
36
+ Short links are deleted after 30 days unused, unless made with --keep or
37
+ saved from the link's waiting page. Full /d links never expire.
31
38
  `;
32
39
 
33
40
  /**
@@ -35,6 +42,7 @@ Use --raw to keep it local regardless of size.
35
42
  * @property {string|null} file
36
43
  * @property {boolean} raw
37
44
  * @property {boolean} short
45
+ * @property {boolean} keep
38
46
  * @property {boolean} open
39
47
  * @property {boolean} copy
40
48
  * @property {string} baseUrl
@@ -49,7 +57,7 @@ Use --raw to keep it local regardless of size.
49
57
  export function parseArgs(argv) {
50
58
  /** @type {Options} */
51
59
  const opts = {
52
- file: null, raw: false, short: false, open: false, copy: false,
60
+ file: null, raw: false, short: false, keep: false, open: false, copy: false,
53
61
  baseUrl: DEFAULT_BASE_URL, help: false, version: false,
54
62
  };
55
63
  for (let i = 0; i < argv.length; i++) {
@@ -57,6 +65,7 @@ export function parseArgs(argv) {
57
65
  switch (arg) {
58
66
  case "--raw": opts.raw = true; break;
59
67
  case "--short": opts.short = true; break;
68
+ case "--keep": opts.keep = true; break;
60
69
  case "--open": opts.open = true; break;
61
70
  case "--copy": opts.copy = true; break;
62
71
  case "-h": case "--help": opts.help = true; break;
@@ -83,12 +92,39 @@ export function parseArgs(argv) {
83
92
  if (opts.raw && opts.short) {
84
93
  throw new JotviewError("usage", "--raw and --short cannot be combined");
85
94
  }
95
+ if (opts.raw && opts.keep) {
96
+ throw new JotviewError("usage", "--keep has no effect with --raw: full links never expire");
97
+ }
86
98
  if (opts.short && opts.baseUrl !== DEFAULT_BASE_URL) {
87
99
  throw new JotviewError("usage", `--short only works with ${DEFAULT_BASE_URL}`);
88
100
  }
89
101
  return opts;
90
102
  }
91
103
 
104
+ /**
105
+ * Flags after `jotview mcp`. Only the ones that mean something to a server.
106
+ * @param {string[]} argv
107
+ * @returns {{raw: boolean, baseUrl: string}}
108
+ */
109
+ export function parseMcpArgs(argv) {
110
+ const opts = { raw: false, baseUrl: DEFAULT_BASE_URL };
111
+ for (let i = 0; i < argv.length; i++) {
112
+ const arg = argv[i];
113
+ if (arg === "--raw") {
114
+ opts.raw = true;
115
+ } else if (arg === "--base-url") {
116
+ const value = argv[++i];
117
+ if (value === undefined || value.startsWith("-") || !/^https?:\/\//.test(value)) {
118
+ throw new JotviewError("usage", "--base-url needs an absolute http:// or https:// URL");
119
+ }
120
+ opts.baseUrl = value.replace(/\/+$/, "");
121
+ } else {
122
+ throw new JotviewError("usage", `jotview mcp does not take ${arg}`);
123
+ }
124
+ }
125
+ return opts;
126
+ }
127
+
92
128
  /** @param {import("node:stream").Readable} stdin */
93
129
  async function readStdin(stdin) {
94
130
  /** @type {Buffer[]} */
@@ -156,6 +192,8 @@ function applySideEffects(url, opts, io) {
156
192
  * @returns {Promise<number>} exit code
157
193
  */
158
194
  export async function run(argv, io) {
195
+ if (argv[0] === "mcp") return runMcp(argv.slice(1), io);
196
+
159
197
  /** @type {Options} */
160
198
  let opts;
161
199
  try {
@@ -173,34 +211,33 @@ export async function run(argv, io) {
173
211
 
174
212
  try {
175
213
  const md = await readInput(opts, io);
176
- if (md.trim() === "") throw new JotviewError("empty", "input is empty");
177
- const rawUrl = `${opts.baseUrl}/d#${encode(md)}`;
178
-
179
- if (!shouldShorten(rawUrl, opts)) {
180
- // Over the threshold, but the host isn't the shortenable default —
181
- // don't nag about it on documents that were never going to need it.
182
- if (opts.baseUrl !== DEFAULT_BASE_URL && rawUrl.length > SHORTEN_THRESHOLD) {
183
- io.stderr(
184
- `note: shortening skipped — only ${DEFAULT_BASE_URL} links can be ` +
185
- "shortened. using the full link.\n",
186
- );
187
- }
188
- io.stdout(`${rawUrl}\n`);
189
- applySideEffects(rawUrl, opts, io);
190
- return 0;
191
- }
214
+ const minted = await mint(md, {
215
+ baseUrl: opts.baseUrl,
216
+ shorten: opts.raw ? "never" : opts.short ? "always" : "auto",
217
+ keep: opts.keep,
218
+ });
219
+ const printed = minted.url;
192
220
 
193
- const result = await shorten(rawUrl);
194
- let printed = rawUrl;
195
- if (result.ok) {
196
- printed = result.short;
221
+ if (minted.skipped === "custom-base") {
222
+ // Only reported when the link was over the threshold — documents that
223
+ // were never going to need shortening don't get nagged about it.
197
224
  io.stderr(
198
- `note: uploaded to jotbins to shorten (${rawUrl.length} → ` +
225
+ `note: shortening skipped — only ${DEFAULT_BASE_URL} links can be ` +
226
+ "shortened. using the full link.\n",
227
+ );
228
+ } else if (minted.shortened) {
229
+ io.stderr(
230
+ `note: uploaded to jotbins to shorten (${minted.rawUrl.length} → ` +
199
231
  `${printed.length} chars). use --raw to keep it local.\n`,
200
232
  );
201
- } else {
202
233
  io.stderr(
203
- `note: upload to the shortener failed (${result.reason}); using the full ` +
234
+ opts.keep
235
+ ? "note: short link kept forever.\n"
236
+ : "note: short link expires after 30 days unused. use --keep to keep it forever.\n",
237
+ );
238
+ } else if (minted.failure) {
239
+ io.stderr(
240
+ `note: upload to the shortener failed (${minted.failure}); using the full ` +
204
241
  `link. the document may still have reached ${SHORTENER_HOST}.\n`,
205
242
  );
206
243
  }
@@ -212,3 +249,29 @@ export async function run(argv, io) {
212
249
  return 1;
213
250
  }
214
251
  }
252
+
253
+ /**
254
+ * `jotview mcp`: stdout belongs to the protocol from here on, so usage
255
+ * errors go to stderr and nothing else is ever printed.
256
+ * @param {string[]} argv
257
+ * @param {Parameters<typeof run>[1]} io
258
+ * @returns {Promise<number>}
259
+ */
260
+ async function runMcp(argv, io) {
261
+ let opts;
262
+ try {
263
+ opts = parseMcpArgs(argv);
264
+ } catch (err) {
265
+ io.stderr(`jotview: ${/** @type {Error} */ (err).message}\n\n${HELP}`);
266
+ return 2;
267
+ }
268
+ await serve({
269
+ input: io.stdin,
270
+ write: io.stdout,
271
+ ctx: {
272
+ version: io.version,
273
+ tools: [renderTool({ allowUpload: !opts.raw, baseUrl: opts.baseUrl })],
274
+ },
275
+ });
276
+ return 0;
277
+ }
@@ -0,0 +1,39 @@
1
+ import { createInterface } from "node:readline";
2
+ import { handleMessage, parseError } from "./mcp.js";
3
+
4
+ /**
5
+ * Serve MCP over stdio: one JSON-RPC message per line in, one per line out.
6
+ * Requests are handled concurrently — each response carries its own id.
7
+ * Resolves once the client closes stdin and every pending reply is written.
8
+ *
9
+ * @param {{
10
+ * input: import("node:stream").Readable,
11
+ * write: (s: string) => void,
12
+ * ctx: import("./mcp.js").Context,
13
+ * }} opts
14
+ */
15
+ export async function serve({ input, write, ctx }) {
16
+ /** @param {object|null} res */
17
+ const send = (res) => {
18
+ // JSON.stringify never emits a raw newline, so every message stays one line.
19
+ if (res) write(`${JSON.stringify(res)}\n`);
20
+ };
21
+
22
+ /** @type {Promise<void>[]} */
23
+ const pending = [];
24
+ // readline reassembles messages split across chunks and splits chunks that
25
+ // carry several messages.
26
+ for await (const raw of createInterface({ input, crlfDelay: Infinity })) {
27
+ const text = raw.trim();
28
+ if (text === "") continue;
29
+ let msg;
30
+ try {
31
+ msg = JSON.parse(text);
32
+ } catch {
33
+ send(parseError());
34
+ continue;
35
+ }
36
+ pending.push(handleMessage(msg, ctx).then(send));
37
+ }
38
+ await Promise.all(pending);
39
+ }
@@ -0,0 +1,138 @@
1
+ import { mint } from "./mint.js";
2
+ import { shorten, DEFAULT_BASE_URL, SHORTENER_URL } from "./shorten.js";
3
+ import { JotviewError } from "./codec.js";
4
+
5
+ /** Every character of a /d link costs the user tokens twice — once handed
6
+ * back to the model, once written into its reply — so MCP shortens much
7
+ * sooner than the CLI's 2000. */
8
+ export const MCP_SHORTEN_THRESHOLD = 500;
9
+
10
+ const SHORTENER_HOST = new URL(SHORTENER_URL).host;
11
+ const TITLE_MAX = 80;
12
+ const MODES = ["auto", "always", "never"];
13
+
14
+ const DEFINITION = {
15
+ name: "render_markdown",
16
+ title: "Render markdown with jotbins",
17
+ description:
18
+ "Turn markdown (GitHub-flavored, with ```mermaid diagrams) into a link that " +
19
+ "renders it as a page on jotbins.com. Returns a ready-made markdown link to " +
20
+ "show the user. Small documents live entirely in the link; larger ones are " +
21
+ "uploaded to surl.jotbins.com for a short link that expires after 30 days " +
22
+ "unused unless keep is true.",
23
+ inputSchema: {
24
+ type: "object",
25
+ properties: {
26
+ content: { type: "string", description: "The markdown to render." },
27
+ keep: {
28
+ type: "boolean",
29
+ description: "Make a shortened link permanent instead of expiring after 30 days unused.",
30
+ },
31
+ shorten: {
32
+ type: "string",
33
+ enum: MODES,
34
+ description:
35
+ 'Default "auto" shortens long links. "never" keeps the document out of ' +
36
+ 'any upload (long link); "always" shortens even small ones.',
37
+ },
38
+ },
39
+ required: ["content"],
40
+ additionalProperties: false,
41
+ },
42
+ annotations: { readOnlyHint: true, openWorldHint: true },
43
+ };
44
+
45
+ /**
46
+ * The first markdown heading, as safe link text.
47
+ * @param {string} md
48
+ */
49
+ export function docTitle(md) {
50
+ const match = md.match(/^#{1,6}[ \t]+(.+?)[ \t#]*$/m);
51
+ const title = match ? match[1].trim().slice(0, TITLE_MAX) : "jotbins doc";
52
+ return title.replace(/[\\[\]]/g, (c) => `\\${c}`);
53
+ }
54
+
55
+ /** @param {string} text */
56
+ const toolError = (text) => ({ content: [{ type: "text", text }], isError: true });
57
+
58
+ /**
59
+ * @param {Record<string, unknown>} args
60
+ * @returns {string|null} what's wrong, phrased for the model to fix
61
+ */
62
+ function validate(args) {
63
+ if (typeof args.content !== "string") return "content is required: pass the markdown to render as a string.";
64
+ if (args.content.trim() === "") return "content is empty: pass the markdown to render.";
65
+ if (args.keep !== undefined && typeof args.keep !== "boolean") return "keep must be true or false.";
66
+ if (args.shorten !== undefined && !MODES.includes(/** @type {string} */ (args.shorten))) {
67
+ return 'shorten must be "auto", "always" or "never".';
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * @param {import("./mint.js").MintResult} r
74
+ * @param {boolean} keepAsked
75
+ */
76
+ function describe(r, keepAsked) {
77
+ if (r.shortened) {
78
+ return r.permanent
79
+ ? `Short link: the document was uploaded to ${SHORTENER_HOST} and the link is permanent.`
80
+ : `Short link: the document was uploaded to ${SHORTENER_HOST}. It is deleted after 30 days ` +
81
+ "without a visit; call again with keep: true for a permanent link.";
82
+ }
83
+ const notes = [];
84
+ if (r.skipped === "uploads-off") {
85
+ notes.push("Full link: uploads are turned off for this server, so it was not shortened. Nothing was uploaded.");
86
+ } else if (r.skipped === "custom-base") {
87
+ notes.push(`Full link: only ${DEFAULT_BASE_URL} links can be shortened. Nothing was uploaded.`);
88
+ } else if (r.failure) {
89
+ notes.push(
90
+ `Full link: shortening failed (${r.failure}). The document may still have reached ${SHORTENER_HOST}.`,
91
+ );
92
+ } else {
93
+ notes.push("Full link: the document lives in the link itself; nothing was uploaded and it never expires.");
94
+ }
95
+ if (keepAsked) notes.push("keep has no effect here: full links never expire.");
96
+ return notes.join(" ");
97
+ }
98
+
99
+ /**
100
+ * @param {{
101
+ * allowUpload?: boolean,
102
+ * baseUrl?: string,
103
+ * shortenImpl?: typeof shorten,
104
+ * }} opts `allowUpload: false` is `jotview mcp --raw`
105
+ * @returns {import("./mcp.js").Tool}
106
+ */
107
+ export function renderTool(opts) {
108
+ return {
109
+ definition: DEFINITION,
110
+ async call(args) {
111
+ const problem = validate(args);
112
+ if (problem) return toolError(problem);
113
+
114
+ const md = /** @type {string} */ (args.content);
115
+ const keep = args.keep === true;
116
+ let r;
117
+ try {
118
+ r = await mint(md, {
119
+ baseUrl: opts.baseUrl,
120
+ shorten: /** @type {import("./mint.js").ShortenMode} */ (args.shorten ?? "auto"),
121
+ keep,
122
+ allowUpload: opts.allowUpload ?? true,
123
+ threshold: MCP_SHORTEN_THRESHOLD,
124
+ shortenImpl: opts.shortenImpl ?? shorten,
125
+ });
126
+ } catch (err) {
127
+ if (err instanceof JotviewError) return toolError(`${err.message}. Shorten or split the document.`);
128
+ throw err;
129
+ }
130
+
131
+ const text =
132
+ `[${docTitle(md)}](${r.url})\n\n` +
133
+ `${describe(r, keep && !r.shortened)}\n` +
134
+ "Show it to the user as the markdown link above, not as a bare URL.";
135
+ return { content: [{ type: "text", text }] };
136
+ },
137
+ };
138
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * MCP message handling — pure, no I/O. `mcp-stdio.js` owns the transport.
3
+ *
4
+ * jotview is a dual-era server. Clients on the handshake revisions open with
5
+ * `initialize`; clients on 2026-07-28 open with `server/discover` and put the
6
+ * protocol version in every request's `_meta`. The one tool is stateless, so
7
+ * both eras are served from the same handlers and only the envelope differs.
8
+ */
9
+
10
+ /** Newest first: `initialize` answers with the first one the client lacks. */
11
+ export const LEGACY_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
12
+ export const MODERN_VERSIONS = ["2026-07-28"];
13
+
14
+ export const INSTRUCTIONS =
15
+ "Use render_markdown to share markdown — including Mermaid diagrams — as a " +
16
+ "rendered jotbins page. Show the returned link to the user as a markdown " +
17
+ "link, [title](url); never paste a bare jotbins.com/d URL, it can be " +
18
+ "thousands of characters. Long documents are uploaded to shorten the link " +
19
+ 'unless the user asked to keep them local (shorten: "never").';
20
+
21
+ const META_VERSION = "io.modelcontextprotocol/protocolVersion";
22
+ const META_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities";
23
+
24
+ const PARSE_ERROR = -32700;
25
+ const INVALID_REQUEST = -32600;
26
+ const METHOD_NOT_FOUND = -32601;
27
+ const INVALID_PARAMS = -32602;
28
+ const INTERNAL_ERROR = -32603;
29
+ const UNSUPPORTED_VERSION = -32022;
30
+
31
+ /**
32
+ * @typedef {object} Tool
33
+ * @property {{name: string} & Record<string, unknown>} definition as listed by tools/list
34
+ * @property {(args: Record<string, unknown>) => Promise<object>} call returns a CallToolResult
35
+ */
36
+
37
+ /**
38
+ * @typedef {object} Context
39
+ * @property {string} version jotview's own version, reported as serverInfo
40
+ * @property {Tool[]} tools
41
+ */
42
+
43
+ /**
44
+ * @param {string|number|null} id
45
+ * @param {number} code
46
+ * @param {string} message
47
+ * @param {unknown} [data]
48
+ */
49
+ export function errorResponse(id, code, message, data) {
50
+ return { jsonrpc: "2.0", id, error: { code, message, ...(data !== undefined && { data }) } };
51
+ }
52
+
53
+ /** For the transport: a line that wasn't JSON at all. */
54
+ export const parseError = () => errorResponse(null, PARSE_ERROR, "Parse error");
55
+
56
+ /** @param {Context} ctx */
57
+ const serverInfo = (ctx) => ({ name: "jotview", version: ctx.version });
58
+
59
+ /**
60
+ * Handle one parsed JSON-RPC message.
61
+ * @param {unknown} msg
62
+ * @param {Context} ctx
63
+ * @returns {Promise<object|null>} the response, or null when none is owed
64
+ */
65
+ export async function handleMessage(msg, ctx) {
66
+ if (Array.isArray(msg)) {
67
+ return errorResponse(null, INVALID_REQUEST, "Batches are not supported");
68
+ }
69
+ if (msg === null || typeof msg !== "object") {
70
+ return errorResponse(null, INVALID_REQUEST, "Invalid request");
71
+ }
72
+
73
+ const m = /** @type {Record<string, any>} */ (msg);
74
+ const hasId = "id" in m;
75
+ const id = hasId ? m.id : null;
76
+
77
+ // A response to something we sent. jotview never sends requests, so there
78
+ // is nothing to match it against.
79
+ if (typeof m.method !== "string" && hasId && ("result" in m || "error" in m)) return null;
80
+ if (m.jsonrpc !== "2.0" || typeof m.method !== "string") {
81
+ return hasId ? errorResponse(id, INVALID_REQUEST, "Invalid request") : null;
82
+ }
83
+ if (!hasId) return null; // notifications never get a reply
84
+
85
+ const params = m.params && typeof m.params === "object" ? m.params : {};
86
+ const meta = params._meta && typeof params._meta === "object" ? params._meta : {};
87
+ const modern = META_VERSION in meta;
88
+
89
+ if (modern) {
90
+ const requested = meta[META_VERSION];
91
+ if (!MODERN_VERSIONS.includes(requested)) {
92
+ return errorResponse(id, UNSUPPORTED_VERSION, "Unsupported protocol version", {
93
+ supported: MODERN_VERSIONS,
94
+ requested,
95
+ });
96
+ }
97
+ if (m.method !== "server/discover" && !(META_CAPABILITIES in meta)) {
98
+ return errorResponse(id, INVALID_PARAMS, `Missing _meta["${META_CAPABILITIES}"]`);
99
+ }
100
+ }
101
+
102
+ /** @param {object} result */
103
+ const ok = (result) => ({
104
+ jsonrpc: "2.0",
105
+ id,
106
+ result: modern ? { resultType: "complete", ...result } : result,
107
+ });
108
+
109
+ switch (m.method) {
110
+ case "initialize": {
111
+ const asked = params.protocolVersion;
112
+ return {
113
+ jsonrpc: "2.0",
114
+ id,
115
+ result: {
116
+ protocolVersion: LEGACY_VERSIONS.includes(asked) ? asked : LEGACY_VERSIONS[0],
117
+ capabilities: { tools: { listChanged: false } },
118
+ serverInfo: serverInfo(ctx),
119
+ instructions: INSTRUCTIONS,
120
+ },
121
+ };
122
+ }
123
+
124
+ case "server/discover":
125
+ return {
126
+ jsonrpc: "2.0",
127
+ id,
128
+ result: {
129
+ resultType: "complete",
130
+ supportedVersions: MODERN_VERSIONS,
131
+ capabilities: { tools: {} },
132
+ _meta: { "io.modelcontextprotocol/serverInfo": serverInfo(ctx) },
133
+ instructions: INSTRUCTIONS,
134
+ ttlMs: 60 * 60 * 1000,
135
+ cacheScope: "public",
136
+ },
137
+ };
138
+
139
+ case "ping":
140
+ return ok({});
141
+
142
+ case "tools/list":
143
+ return ok({ tools: ctx.tools.map((t) => t.definition) });
144
+
145
+ case "tools/call": {
146
+ const tool = ctx.tools.find((t) => t.definition.name === params.name);
147
+ if (!tool) return errorResponse(id, INVALID_PARAMS, `Unknown tool: ${params.name}`);
148
+ const args = params.arguments && typeof params.arguments === "object" ? params.arguments : {};
149
+ try {
150
+ return ok(await tool.call(args));
151
+ } catch (err) {
152
+ return errorResponse(id, INTERNAL_ERROR, /** @type {Error} */ (err).message);
153
+ }
154
+ }
155
+
156
+ default:
157
+ return errorResponse(id, METHOD_NOT_FOUND, `Method not found: ${m.method}`);
158
+ }
159
+ }
package/src/mint.js ADDED
@@ -0,0 +1,62 @@
1
+ import { encode, JotviewError } from "./codec.js";
2
+ import { shorten, DEFAULT_BASE_URL, SHORTEN_THRESHOLD } from "./shorten.js";
3
+
4
+ /** @typedef {"auto" | "always" | "never"} ShortenMode */
5
+
6
+ /**
7
+ * @typedef {object} MintResult
8
+ * @property {string} url what to hand the user — short when shortened
9
+ * @property {string} rawUrl the full /d link, always
10
+ * @property {boolean} shortened
11
+ * @property {boolean} permanent only true for a shortened link made with `keep`
12
+ * @property {string|null} failure the shortener's error, when it was tried and failed
13
+ * @property {"custom-base"|"uploads-off"|null} skipped
14
+ * why a link that would have been shortened was not
15
+ */
16
+
17
+ /**
18
+ * Turn markdown into a jotbins link, shortening it through surl when the
19
+ * mode and size call for it. Shared by the CLI and the MCP server so both
20
+ * make the same decision; each words the outcome for its own audience.
21
+ *
22
+ * @param {string} md
23
+ * @param {{
24
+ * baseUrl?: string,
25
+ * shorten?: ShortenMode,
26
+ * keep?: boolean,
27
+ * allowUpload?: boolean,
28
+ * threshold?: number,
29
+ * shortenImpl?: typeof shorten,
30
+ * }} [opts]
31
+ * @returns {Promise<MintResult>}
32
+ */
33
+ export async function mint(md, opts = {}) {
34
+ const {
35
+ baseUrl = DEFAULT_BASE_URL,
36
+ shorten: mode = "auto",
37
+ keep = false,
38
+ allowUpload = true,
39
+ threshold = SHORTEN_THRESHOLD,
40
+ shortenImpl = shorten,
41
+ } = opts;
42
+
43
+ if (md.trim() === "") throw new JotviewError("empty", "input is empty");
44
+ const rawUrl = `${baseUrl}/d#${encode(md)}`;
45
+
46
+ /** @type {MintResult} */
47
+ const result = {
48
+ url: rawUrl, rawUrl, shortened: false, permanent: false, failure: null, skipped: null,
49
+ };
50
+
51
+ const wanted = mode === "always" || (mode === "auto" && rawUrl.length > threshold);
52
+ if (!wanted) return result;
53
+
54
+ // surl's allowlist accepts only jotbins.com hosts, so a custom base URL can
55
+ // never be shortened. Skip rather than fire a request guaranteed to 400.
56
+ if (baseUrl !== DEFAULT_BASE_URL) return { ...result, skipped: "custom-base" };
57
+ if (!allowUpload) return { ...result, skipped: "uploads-off" };
58
+
59
+ const outcome = await shortenImpl(rawUrl, { permanent: keep });
60
+ if (!outcome.ok) return { ...result, failure: outcome.reason };
61
+ return { ...result, url: outcome.short, shortened: true, permanent: keep };
62
+ }
package/src/shorten.js CHANGED
@@ -28,7 +28,9 @@ export function shouldShorten(url, opts) {
28
28
  /**
29
29
  * Shorten a URL. Never throws — the caller falls back to the raw URL.
30
30
  * @param {string} url
31
- * @param {{endpoint?: string}} [options]
31
+ * @param {{endpoint?: string, permanent?: boolean}} [options]
32
+ * `permanent` asks surl for a link that never expires. Left out of the body
33
+ * when false, so the request matches what older surl versions expect.
32
34
  * @returns {Promise<{ok: true, short: string} | {ok: false, reason: string}>}
33
35
  */
34
36
  export async function shorten(url, options = {}) {
@@ -37,7 +39,7 @@ export async function shorten(url, options = {}) {
37
39
  const response = await fetch(endpoint, {
38
40
  method: "POST",
39
41
  headers: { "content-type": "application/json" },
40
- body: JSON.stringify({ url }),
42
+ body: JSON.stringify(options.permanent ? { url, permanent: true } : { url }),
41
43
  signal: AbortSignal.timeout(TIMEOUT_MS),
42
44
  });
43
45
  if (!response.ok) {