postmd-mcp-server 2.1.1 → 2.2.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 +9 -4
- package/package.json +1 -1
- package/scripts/smoke-test.mjs +45 -0
- package/server.json +2 -2
- package/src/index.js +64 -19
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ stdio [Model Context Protocol](https://modelcontextprotocol.io) server for **[Po
|
|
|
4
4
|
|
|
5
5
|
**Publishing needs no account and no key.** With zero configuration this server can already turn Markdown into a shareable page. An API key adds management: updating and deleting your documents, attachments, and groups.
|
|
6
6
|
|
|
7
|
+
**Anonymous documents come with a control token.** Publishing without a key returns `data.controlToken` and `data.retainedUntil`: the document is deleted at that instant, and the token is the only way to update or delete it before then. It is shown once and cannot be reissued, so keep it with the `docCode`. Pass it as `controlToken` to the update and delete tools and they work without an API key.
|
|
8
|
+
|
|
7
9
|
**HTTP reference:** [postmd.turink.com/docs/api](https://postmd.turink.com/docs/api) · machine-readable spec at [/api-docs](https://postmd.turink.com/api-docs)
|
|
8
10
|
|
|
9
11
|
## Requirements
|
|
@@ -36,19 +38,22 @@ Publishing and reading — no key needed:
|
|
|
36
38
|
|
|
37
39
|
Managing documents — key with `documents:write`:
|
|
38
40
|
|
|
41
|
+
Each of the first three also accepts `controlToken` instead of a key, for a document published anonymously.
|
|
42
|
+
|
|
39
43
|
| Tool | Purpose |
|
|
40
44
|
|------|---------|
|
|
41
45
|
| `postmd_update_document` | Replace content and/or metadata; can clear password / end date |
|
|
42
46
|
| `postmd_update_document_from_file` | Same, body read from a local `filePath` |
|
|
43
|
-
| `postmd_delete_document` | Delete
|
|
47
|
+
| `postmd_delete_document` | Delete a document (no undo) |
|
|
44
48
|
| `postmd_upload_attachment` | Upload an image/PDF, get a URL to embed in Markdown |
|
|
45
49
|
| `postmd_create_documents_from_files` | Bulk-publish several `.md` files in one call |
|
|
46
50
|
| `postmd_move_document_to_group` | Move a document into a group / folder |
|
|
47
51
|
|
|
48
52
|
Notes and highlights — key with `documents:read` / `documents:write`. A note is
|
|
49
53
|
text anchored to a quoted passage; a highlight is the same object carrying only
|
|
50
|
-
a colour.
|
|
51
|
-
|
|
54
|
+
a colour. Visibility comes from ownership: on the key member's own document a note
|
|
55
|
+
is `PRIVATE` or `SHARED`, and on anyone else's document it is always `SHARED`.
|
|
56
|
+
Documents nobody owns — anonymous uploads and service-owned pages — take no notes:
|
|
52
57
|
|
|
53
58
|
| Tool | Purpose |
|
|
54
59
|
|------|---------|
|
|
@@ -125,7 +130,7 @@ export POSTMD_API_KEY=pmk_…
|
|
|
125
130
|
npm run smoke
|
|
126
131
|
```
|
|
127
132
|
|
|
128
|
-
Creates a group and a passworded document, reads it back, updates it, clears the password, then deletes both.
|
|
133
|
+
Creates a group and a passworded document, reads it back, updates it, clears the password, then deletes both. It also publishes one document with no credential and removes it with the control token.
|
|
129
134
|
|
|
130
135
|
## Stack
|
|
131
136
|
|
package/package.json
CHANGED
package/scripts/smoke-test.mjs
CHANGED
|
@@ -108,5 +108,50 @@ if (groupId != null) {
|
|
|
108
108
|
check("delete group", dropped.json?.resultCode === "200", dropped.text);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/*
|
|
112
|
+
9. 익명 발행과 제어 토큰.
|
|
113
|
+
|
|
114
|
+
자격 증명을 붙이지 않고 부른다. 키를 실으면 그 회원 소유가 되어 토큰이 나오지 않으므로,
|
|
115
|
+
여기서만 Authorization 헤더를 뺀다.
|
|
116
|
+
*/
|
|
117
|
+
const anonForm = new FormData();
|
|
118
|
+
anonForm.append(
|
|
119
|
+
"file",
|
|
120
|
+
new Blob([`# Smoke anon\n\n${MARKER}\n`], { type: "text/markdown" }),
|
|
121
|
+
"smoke-anon.md"
|
|
122
|
+
);
|
|
123
|
+
const anonRes = await fetch(`${base}/api/v1/documents`, { method: "POST", body: anonForm });
|
|
124
|
+
const anon = await anonRes.json().catch(() => null);
|
|
125
|
+
check("publish without a credential", anon?.resultCode === "200", JSON.stringify(anon));
|
|
126
|
+
|
|
127
|
+
const anonCode = anon?.data?.docCode;
|
|
128
|
+
const controlToken = anon?.data?.controlToken;
|
|
129
|
+
check("answer carries a control token", typeof controlToken === "string" && controlToken.startsWith("pmt_"));
|
|
130
|
+
check("answer carries a deletion date", typeof anon?.data?.retainedUntil === "string");
|
|
131
|
+
check("answer explains the terms", typeof anon?.message === "string" && anon.message.length > 0);
|
|
132
|
+
|
|
133
|
+
if (anonCode && controlToken) {
|
|
134
|
+
const titled = new FormData();
|
|
135
|
+
titled.append("title", `mcp smoke anon ${stamp}`);
|
|
136
|
+
const changed = await fetch(`${base}/api/v1/documents/${anonCode}/update`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "X-Document-Token": controlToken },
|
|
139
|
+
body: titled,
|
|
140
|
+
});
|
|
141
|
+
check("token updates the document", (await changed.json().catch(() => null))?.resultCode === "200");
|
|
142
|
+
|
|
143
|
+
const refused = await fetch(`${base}/api/v1/documents/${anonCode}/delete`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { "X-Document-Token": "pmt_wrong" },
|
|
146
|
+
});
|
|
147
|
+
check("a wrong token is refused", (await refused.json().catch(() => null))?.resultCode === "E_DOC_0008");
|
|
148
|
+
|
|
149
|
+
const removed = await fetch(`${base}/api/v1/documents/${anonCode}/delete`, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { "X-Document-Token": controlToken },
|
|
152
|
+
});
|
|
153
|
+
check("token deletes the document", (await removed.json().catch(() => null))?.resultCode === "200");
|
|
154
|
+
}
|
|
155
|
+
|
|
111
156
|
console.log(failures ? `\n${failures} failure(s)` : "\nall good");
|
|
112
157
|
process.exit(failures ? 1 : 0);
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.reinlainer/postmd-mcp-server",
|
|
4
4
|
"description": "Publish Markdown to PostMD and get a shareable web page",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"url": "https://github.com/reinlainer/postmd-mcp-server",
|
|
8
8
|
"source": "github"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "postmd-mcp-server",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.2.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
package/src/index.js
CHANGED
|
@@ -17,7 +17,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
17
17
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
18
18
|
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
19
19
|
|
|
20
|
-
const VERSION = "2.
|
|
20
|
+
const VERSION = "2.2.0";
|
|
21
21
|
|
|
22
22
|
/** 기본은 운영이다. 대부분의 사용자는 설정 없이 바로 쓰면 된다. */
|
|
23
23
|
const DEFAULT_BASE_URL = "https://postmd.turink.com";
|
|
@@ -28,7 +28,10 @@ const SERVER_INSTRUCTIONS =
|
|
|
28
28
|
"the HTTP API directly. Creating a document needs no API key; updating, deleting, " +
|
|
29
29
|
"attachments and groups need POSTMD_API_KEY with the matching scope. Pass the full " +
|
|
30
30
|
"Markdown in `markdown`, or pass a local `filePath` so this server reads the file " +
|
|
31
|
-
"itself. A successful create returns data.shareUrl — hand that URL to people."
|
|
31
|
+
"itself. A successful create returns data.shareUrl — hand that URL to people. " +
|
|
32
|
+
"Creating without a key also returns data.controlToken and data.retainedUntil: the " +
|
|
33
|
+
"document is deleted at that instant, and the token is the only way to update or " +
|
|
34
|
+
"delete it. It is shown once, so report it to the person along with the URL.";
|
|
32
35
|
|
|
33
36
|
function isDebug() {
|
|
34
37
|
const v = process.env.POSTMD_DEBUG;
|
|
@@ -222,11 +225,40 @@ async function updateDocument(ctx, a, markdownBuffer) {
|
|
|
222
225
|
}
|
|
223
226
|
const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/update`, {
|
|
224
227
|
method: "POST",
|
|
228
|
+
headers: tokenHeader(a),
|
|
225
229
|
body: form,
|
|
226
230
|
});
|
|
227
231
|
return fromEnvelope(r);
|
|
228
232
|
}
|
|
229
233
|
|
|
234
|
+
/**
|
|
235
|
+
* 익명 발행 문서의 제어 토큰 인자.
|
|
236
|
+
*
|
|
237
|
+
* 키를 대신하는 것이 아니라 그 문서 하나에만 듣는다. 그래서 키가 없어도 이 값이 있으면
|
|
238
|
+
* 도구를 부를 수 있고, 키가 있어도 남의 익명 문서에는 이 값이 있어야 한다.
|
|
239
|
+
*/
|
|
240
|
+
const CONTROL_TOKEN_PROP = {
|
|
241
|
+
type: "string",
|
|
242
|
+
description:
|
|
243
|
+
"Control token from an anonymous publish answer (pmt_...). Lets you act on that one " +
|
|
244
|
+
"document without an API key.",
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/** 제어 토큰을 헤더로 옮긴다. 서버는 회원 인증 헤더와 나눠서 받는다. */
|
|
248
|
+
function tokenHeader(a) {
|
|
249
|
+
return a.controlToken ? { "X-Document-Token": String(a.controlToken) } : {};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 키가 없어도 제어 토큰이 있으면 통과시킨다.
|
|
254
|
+
*
|
|
255
|
+
* 토큰만으로 되는 일에 키를 요구하면, 방금 익명으로 발행하고 토큰을 받은 쪽이 자기 문서를
|
|
256
|
+
* 손대지 못한다.
|
|
257
|
+
*/
|
|
258
|
+
function missingKeyUnlessToken(ctx, a, scopes) {
|
|
259
|
+
return a.controlToken ? null : missingKey(ctx, scopes);
|
|
260
|
+
}
|
|
261
|
+
|
|
230
262
|
/** 문서 메타데이터 공통 속성. 만들기·고치기 스키마가 나눠 쓴다. */
|
|
231
263
|
const DOC_META_PROPS = {
|
|
232
264
|
password: { type: "string", description: "Readers must supply this password to see the content." },
|
|
@@ -246,8 +278,11 @@ const TOOL_DEFS = [
|
|
|
246
278
|
name: "postmd_create_document",
|
|
247
279
|
description:
|
|
248
280
|
"Publish Markdown as a PostMD web page. No API key required — anyone can publish. " +
|
|
249
|
-
"Returns docCode and data.shareUrl; hand shareUrl to people.
|
|
250
|
-
"document
|
|
281
|
+
"Returns docCode and data.shareUrl; hand shareUrl to people. Without a key the " +
|
|
282
|
+
"document is anonymous: data.retainedUntil is when it is deleted and " +
|
|
283
|
+
"data.controlToken is the only way to update or delete it, shown once and never " +
|
|
284
|
+
"reissued — report both to the person. With an API key the document belongs to that " +
|
|
285
|
+
"member, has no expiry, needs no token and can collect notes; groupId files it into " +
|
|
251
286
|
"that group instead of the default one (key with documents:write).",
|
|
252
287
|
inputSchema: {
|
|
253
288
|
type: "object",
|
|
@@ -271,7 +306,8 @@ const TOOL_DEFS = [
|
|
|
271
306
|
name: "postmd_create_document_from_file",
|
|
272
307
|
description:
|
|
273
308
|
"Same as postmd_create_document, but reads the Markdown from filePath on the machine " +
|
|
274
|
-
"running this MCP server — use it for large files instead of pasting the body."
|
|
309
|
+
"running this MCP server — use it for large files instead of pasting the body. " +
|
|
310
|
+
"Without an API key it returns data.controlToken and data.retainedUntil, same as above.",
|
|
275
311
|
inputSchema: {
|
|
276
312
|
type: "object",
|
|
277
313
|
properties: {
|
|
@@ -338,9 +374,11 @@ const TOOL_DEFS = [
|
|
|
338
374
|
{
|
|
339
375
|
name: "postmd_update_document",
|
|
340
376
|
description:
|
|
341
|
-
"Update a document
|
|
342
|
-
"`
|
|
343
|
-
"
|
|
377
|
+
"Update a document. Requires an API key with documents:write for a document you own, " +
|
|
378
|
+
"or `controlToken` for an anonymously published one. Include `markdown` to replace " +
|
|
379
|
+
"the stored content; any metadata field replaces that field. clearPassword / " +
|
|
380
|
+
"clearShareEndDate remove the password / end date. Updating does not push back the " +
|
|
381
|
+
"deletion date of an anonymous document.",
|
|
344
382
|
inputSchema: {
|
|
345
383
|
type: "object",
|
|
346
384
|
properties: {
|
|
@@ -354,6 +392,7 @@ const TOOL_DEFS = [
|
|
|
354
392
|
...DOC_META_PROPS,
|
|
355
393
|
clearPassword: { type: "boolean", description: "true removes the password." },
|
|
356
394
|
clearShareEndDate: { type: "boolean", description: "true removes the end date, making sharing open-ended." },
|
|
395
|
+
controlToken: CONTROL_TOKEN_PROP,
|
|
357
396
|
},
|
|
358
397
|
required: ["docCode"],
|
|
359
398
|
},
|
|
@@ -362,7 +401,7 @@ const TOOL_DEFS = [
|
|
|
362
401
|
name: "postmd_update_document_from_file",
|
|
363
402
|
description:
|
|
364
403
|
"Same as postmd_update_document, but reads the new Markdown from filePath on the " +
|
|
365
|
-
"machine running this MCP server.",
|
|
404
|
+
"machine running this MCP server. Takes `controlToken` the same way.",
|
|
366
405
|
inputSchema: {
|
|
367
406
|
type: "object",
|
|
368
407
|
properties: {
|
|
@@ -376,6 +415,7 @@ const TOOL_DEFS = [
|
|
|
376
415
|
...DOC_META_PROPS,
|
|
377
416
|
clearPassword: { type: "boolean", description: "true removes the password." },
|
|
378
417
|
clearShareEndDate: { type: "boolean", description: "true removes the end date, making sharing open-ended." },
|
|
418
|
+
controlToken: CONTROL_TOKEN_PROP,
|
|
379
419
|
},
|
|
380
420
|
required: ["docCode", "filePath"],
|
|
381
421
|
},
|
|
@@ -383,11 +423,13 @@ const TOOL_DEFS = [
|
|
|
383
423
|
{
|
|
384
424
|
name: "postmd_delete_document",
|
|
385
425
|
description:
|
|
386
|
-
"Delete a document
|
|
387
|
-
"
|
|
426
|
+
"Delete a document. Requires an API key with documents:write for a document you own, " +
|
|
427
|
+
"or `controlToken` for an anonymously published one. There is no endpoint to undo " +
|
|
428
|
+
"this: the document stops being served at once and its stored content is erased about " +
|
|
429
|
+
"a month later.",
|
|
388
430
|
inputSchema: {
|
|
389
431
|
type: "object",
|
|
390
|
-
properties: { docCode: { type: "string" } },
|
|
432
|
+
properties: { docCode: { type: "string" }, controlToken: CONTROL_TOKEN_PROP },
|
|
391
433
|
required: ["docCode"],
|
|
392
434
|
},
|
|
393
435
|
},
|
|
@@ -432,9 +474,10 @@ const TOOL_DEFS = [
|
|
|
432
474
|
description:
|
|
433
475
|
"Attach a note or highlight to a document. Requires an API key with documents:write. " +
|
|
434
476
|
"Give `content` for a note, `color` alone for a colour-only highlight (then " +
|
|
435
|
-
"`quotedContent` is required — a highlight must point at a passage).
|
|
436
|
-
"
|
|
437
|
-
"
|
|
477
|
+
"`quotedContent` is required — a highlight must point at a passage). Visibility comes " +
|
|
478
|
+
"from ownership: on the key member's own document choose PRIVATE (only they see it) or " +
|
|
479
|
+
"SHARED; on anyone else's document every note is SHARED, so omit scope. Documents " +
|
|
480
|
+
"nobody owns — anonymous uploads and service-owned pages — take no notes at all.",
|
|
438
481
|
inputSchema: {
|
|
439
482
|
type: "object",
|
|
440
483
|
properties: {
|
|
@@ -456,7 +499,8 @@ const TOOL_DEFS = [
|
|
|
456
499
|
name: "postmd_update_note",
|
|
457
500
|
description:
|
|
458
501
|
"Edit a note you wrote. Requires an API key with documents:write. Omitting scope " +
|
|
459
|
-
"keeps the current one
|
|
502
|
+
"keeps the current one; a scope you do send follows the ownership rule above. The " +
|
|
503
|
+
"note must keep text or a colour.",
|
|
460
504
|
inputSchema: {
|
|
461
505
|
type: "object",
|
|
462
506
|
properties: {
|
|
@@ -664,12 +708,12 @@ async function runTool(ctx, name, args) {
|
|
|
664
708
|
}
|
|
665
709
|
}
|
|
666
710
|
case "postmd_update_document": {
|
|
667
|
-
const denied =
|
|
711
|
+
const denied = missingKeyUnlessToken(ctx, a, "documents:write");
|
|
668
712
|
if (denied) return denied;
|
|
669
713
|
return await updateDocument(ctx, a, a.markdown ?? null);
|
|
670
714
|
}
|
|
671
715
|
case "postmd_update_document_from_file": {
|
|
672
|
-
const denied =
|
|
716
|
+
const denied = missingKeyUnlessToken(ctx, a, "documents:write");
|
|
673
717
|
if (denied) return denied;
|
|
674
718
|
try {
|
|
675
719
|
const { buffer, suggestedName } = await readLocalFile(a.filePath);
|
|
@@ -679,10 +723,11 @@ async function runTool(ctx, name, args) {
|
|
|
679
723
|
}
|
|
680
724
|
}
|
|
681
725
|
case "postmd_delete_document": {
|
|
682
|
-
const denied =
|
|
726
|
+
const denied = missingKeyUnlessToken(ctx, a, "documents:write");
|
|
683
727
|
if (denied) return denied;
|
|
684
728
|
const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/delete`, {
|
|
685
729
|
method: "POST",
|
|
730
|
+
headers: tokenHeader(a),
|
|
686
731
|
});
|
|
687
732
|
return fromEnvelope(r);
|
|
688
733
|
}
|