jotview 0.2.0 → 0.4.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 +55 -0
- package/package.json +6 -3
- package/src/cli.js +76 -27
- package/src/mcp-http.js +75 -0
- package/src/mcp-stdio.js +39 -0
- package/src/mcp-tool.js +138 -0
- package/src/mcp.js +159 -0
- package/src/mint.js +62 -0
- package/src/shorten.js +4 -2
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ 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
|
|
@@ -69,6 +70,60 @@ never expire.
|
|
|
69
70
|
If the shortener is unreachable or rate-limited, jotview prints the full link
|
|
70
71
|
and exits 0 — it never fails your pipeline over a shortening problem.
|
|
71
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
|
+
|
|
110
|
+
### Remote server — no install
|
|
111
|
+
|
|
112
|
+
The same tool runs at **`https://mcp.jotbins.com/mcp`** (Streamable HTTP, no
|
|
113
|
+
sign-in), for clients that can't run local commands.
|
|
114
|
+
|
|
115
|
+
- **claude.ai (web and mobile):** Settings → Connectors → Add custom
|
|
116
|
+
connector → paste `https://mcp.jotbins.com/mcp`.
|
|
117
|
+
- **Claude Code:** `claude mcp add -s user --transport http jotview https://mcp.jotbins.com/mcp`
|
|
118
|
+
|
|
119
|
+
Same rules as the local server, except it can't forbid uploads: use
|
|
120
|
+
`shorten: "never"` per call, or the local `--raw` server, to keep a document
|
|
121
|
+
off `surl.jotbins.com`. Uploads are rate-limited per client; over the limit
|
|
122
|
+
you still get the full link.
|
|
123
|
+
|
|
124
|
+
The Worker lives in [`worker/`](worker/) and deploys with
|
|
125
|
+
`cd worker && npx wrangler deploy`.
|
|
126
|
+
|
|
72
127
|
## Output contract
|
|
73
128
|
|
|
74
129
|
**stdout is the URL and nothing else.** Notices, warnings, and errors go to
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jotview",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Turn
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Turn markdown into a jotbins /d link — a CLI and an MCP server. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"jotview": "bin/jotview.js"
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
"markdown",
|
|
23
23
|
"jotbins",
|
|
24
24
|
"cli",
|
|
25
|
-
"share"
|
|
25
|
+
"share",
|
|
26
|
+
"mcp",
|
|
27
|
+
"mcp-server",
|
|
28
|
+
"mermaid"
|
|
26
29
|
],
|
|
27
30
|
"license": "MIT",
|
|
28
31
|
"repository": {
|
package/src/cli.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
} from "./
|
|
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,6 +16,8 @@ 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
|
|
@@ -98,6 +101,30 @@ export function parseArgs(argv) {
|
|
|
98
101
|
return opts;
|
|
99
102
|
}
|
|
100
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
|
+
|
|
101
128
|
/** @param {import("node:stream").Readable} stdin */
|
|
102
129
|
async function readStdin(stdin) {
|
|
103
130
|
/** @type {Buffer[]} */
|
|
@@ -165,6 +192,8 @@ function applySideEffects(url, opts, io) {
|
|
|
165
192
|
* @returns {Promise<number>} exit code
|
|
166
193
|
*/
|
|
167
194
|
export async function run(argv, io) {
|
|
195
|
+
if (argv[0] === "mcp") return runMcp(argv.slice(1), io);
|
|
196
|
+
|
|
168
197
|
/** @type {Options} */
|
|
169
198
|
let opts;
|
|
170
199
|
try {
|
|
@@ -182,29 +211,23 @@ export async function run(argv, io) {
|
|
|
182
211
|
|
|
183
212
|
try {
|
|
184
213
|
const md = await readInput(opts, io);
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (opts.baseUrl !== DEFAULT_BASE_URL && rawUrl.length > SHORTEN_THRESHOLD) {
|
|
192
|
-
io.stderr(
|
|
193
|
-
`note: shortening skipped — only ${DEFAULT_BASE_URL} links can be ` +
|
|
194
|
-
"shortened. using the full link.\n",
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
io.stdout(`${rawUrl}\n`);
|
|
198
|
-
applySideEffects(rawUrl, opts, io);
|
|
199
|
-
return 0;
|
|
200
|
-
}
|
|
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;
|
|
201
220
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
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.
|
|
206
224
|
io.stderr(
|
|
207
|
-
`note:
|
|
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} → ` +
|
|
208
231
|
`${printed.length} chars). use --raw to keep it local.\n`,
|
|
209
232
|
);
|
|
210
233
|
io.stderr(
|
|
@@ -212,9 +235,9 @@ export async function run(argv, io) {
|
|
|
212
235
|
? "note: short link kept forever.\n"
|
|
213
236
|
: "note: short link expires after 30 days unused. use --keep to keep it forever.\n",
|
|
214
237
|
);
|
|
215
|
-
} else {
|
|
238
|
+
} else if (minted.failure) {
|
|
216
239
|
io.stderr(
|
|
217
|
-
`note: upload to the shortener failed (${
|
|
240
|
+
`note: upload to the shortener failed (${minted.failure}); using the full ` +
|
|
218
241
|
`link. the document may still have reached ${SHORTENER_HOST}.\n`,
|
|
219
242
|
);
|
|
220
243
|
}
|
|
@@ -226,3 +249,29 @@ export async function run(argv, io) {
|
|
|
226
249
|
return 1;
|
|
227
250
|
}
|
|
228
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
|
+
}
|
package/src/mcp-http.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { handleMessage, parseError, LEGACY_VERSIONS, MODERN_VERSIONS } from "./mcp.js";
|
|
2
|
+
|
|
3
|
+
/** Docs cap at 1 MB (MAX_DOC_BYTES); leave room for JSON escaping. */
|
|
4
|
+
export const MAX_BODY_BYTES = 2_000_000;
|
|
5
|
+
|
|
6
|
+
const KNOWN_VERSIONS = [...LEGACY_VERSIONS, ...MODERN_VERSIONS];
|
|
7
|
+
|
|
8
|
+
// No cookies or credentials are involved, so any origin may call — that lets
|
|
9
|
+
// browser-based clients such as MCP Inspector connect directly.
|
|
10
|
+
const CORS = {
|
|
11
|
+
"access-control-allow-origin": "*",
|
|
12
|
+
"access-control-allow-methods": "POST, OPTIONS",
|
|
13
|
+
"access-control-allow-headers": "content-type, accept, mcp-protocol-version, mcp-session-id",
|
|
14
|
+
"access-control-max-age": "86400",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** @param {unknown} body @param {number} [status] */
|
|
18
|
+
const json = (body, status = 200) =>
|
|
19
|
+
new Response(JSON.stringify(body), { status, headers: { ...CORS, "content-type": "application/json" } });
|
|
20
|
+
|
|
21
|
+
/** @param {string} text @param {number} status @param {Record<string,string>} [headers] */
|
|
22
|
+
const plain = (text, status, headers = {}) =>
|
|
23
|
+
new Response(text, { status, headers: { ...CORS, "content-type": "text/plain; charset=utf-8", ...headers } });
|
|
24
|
+
|
|
25
|
+
/** @param {string} endpoint */
|
|
26
|
+
const landing = (endpoint) => `jotview — an MCP server that turns markdown into rendered jotbins links.
|
|
27
|
+
|
|
28
|
+
MCP endpoint (Streamable HTTP, no sign-in):
|
|
29
|
+
${endpoint}
|
|
30
|
+
|
|
31
|
+
claude.ai: Settings → Connectors → Add custom connector → paste the URL above.
|
|
32
|
+
Claude Code: claude mcp add -s user --transport http jotview ${endpoint}
|
|
33
|
+
|
|
34
|
+
Source: https://github.com/jotbins/jotview
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Serve MCP over Streamable HTTP. Every request gets exactly one JSON reply —
|
|
39
|
+
* no SSE stream and no sessions, since the only tool answers immediately.
|
|
40
|
+
*
|
|
41
|
+
* @param {Request} request
|
|
42
|
+
* @param {import("./mcp.js").Context} ctx
|
|
43
|
+
* @returns {Promise<Response>}
|
|
44
|
+
*/
|
|
45
|
+
export async function handleHttp(request, ctx) {
|
|
46
|
+
const url = new URL(request.url);
|
|
47
|
+
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
|
|
48
|
+
|
|
49
|
+
if (url.pathname === "/") {
|
|
50
|
+
if (request.method !== "GET") return plain("Method not allowed\n", 405, { allow: "GET" });
|
|
51
|
+
return plain(landing(`${url.origin}/mcp`), 200);
|
|
52
|
+
}
|
|
53
|
+
if (url.pathname !== "/mcp") return plain("Not found\n", 404);
|
|
54
|
+
if (request.method !== "POST") return plain("Method not allowed\n", 405, { allow: "POST, OPTIONS" });
|
|
55
|
+
|
|
56
|
+
const version = request.headers.get("mcp-protocol-version");
|
|
57
|
+
if (version !== null && !KNOWN_VERSIONS.includes(version)) {
|
|
58
|
+
return plain(`Unsupported MCP-Protocol-Version: ${version}\n`, 400);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const declared = Number(request.headers.get("content-length"));
|
|
62
|
+
if (declared > MAX_BODY_BYTES) return plain("Request too large\n", 413);
|
|
63
|
+
const bytes = await request.arrayBuffer();
|
|
64
|
+
if (bytes.byteLength > MAX_BODY_BYTES) return plain("Request too large\n", 413);
|
|
65
|
+
|
|
66
|
+
let msg;
|
|
67
|
+
try {
|
|
68
|
+
msg = JSON.parse(new TextDecoder().decode(bytes));
|
|
69
|
+
} catch {
|
|
70
|
+
return json(parseError(), 400);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const reply = await handleMessage(msg, ctx);
|
|
74
|
+
return reply ? json(reply) : new Response(null, { status: 202, headers: CORS });
|
|
75
|
+
}
|
package/src/mcp-stdio.js
ADDED
|
@@ -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
|
+
}
|
package/src/mcp-tool.js
ADDED
|
@@ -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,15 +28,17 @@ 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, permanent?: boolean}} [options]
|
|
31
|
+
* @param {{endpoint?: string, permanent?: boolean, fetchImpl?: typeof fetch}} [options]
|
|
32
32
|
* `permanent` asks surl for a link that never expires. Left out of the body
|
|
33
33
|
* when false, so the request matches what older surl versions expect.
|
|
34
|
+
* `fetchImpl` lets the Worker route through its surl service binding.
|
|
34
35
|
* @returns {Promise<{ok: true, short: string} | {ok: false, reason: string}>}
|
|
35
36
|
*/
|
|
36
37
|
export async function shorten(url, options = {}) {
|
|
37
38
|
const endpoint = options.endpoint ?? SHORTENER_URL;
|
|
39
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
38
40
|
try {
|
|
39
|
-
const response = await
|
|
41
|
+
const response = await doFetch(endpoint, {
|
|
40
42
|
method: "POST",
|
|
41
43
|
headers: { "content-type": "application/json" },
|
|
42
44
|
body: JSON.stringify(options.permanent ? { url, permanent: true } : { url }),
|