postmd-mcp-server 2.1.1 → 2.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
@@ -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,38 @@ 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 (recoverable for 30 days) |
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
 
52
+ ### Replacing content on a document that has notes
53
+
54
+ Notes are located by the text they quote, not by a stored position. Replacing a
55
+ document's body therefore moves or loses where they point: a note whose quote is
56
+ gone loses its place in the body, and one whose quote now appears elsewhere points
57
+ there instead. The note itself, including the quoted text, is kept either way.
58
+
59
+ Both update tools require `notesOnReplace` whenever new content is sent.
60
+
61
+ | Value | Effect |
62
+ |-------|--------|
63
+ | `keep` | Replaces the content and leaves the notes as they are |
64
+ | `abort` | Refuses when the document has notes anchored to its text, and says how many |
65
+
66
+ Metadata-only updates do not take it.
67
+
48
68
  Notes and highlights — key with `documents:read` / `documents:write`. A note is
49
69
  text anchored to a quoted passage; a highlight is the same object carrying only
50
- a colour. `PRIVATE` notes belong to the key's member; `SHARED` notes are
51
- comments every reader sees:
70
+ a colour. Visibility comes from ownership: on the key member's own document a note
71
+ is `PRIVATE` or `SHARED`, and on anyone else's document it is always `SHARED`.
72
+ Documents nobody owns — anonymous uploads and service-owned pages — take no notes:
52
73
 
53
74
  | Tool | Purpose |
54
75
  |------|---------|
@@ -125,7 +146,7 @@ export POSTMD_API_KEY=pmk_…
125
146
  npm run smoke
126
147
  ```
127
148
 
128
- Creates a group and a passworded document, reads it back, updates it, clears the password, then deletes both.
149
+ 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
150
 
130
151
  ## Stack
131
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postmd-mcp-server",
3
- "version": "2.1.1",
3
+ "version": "2.3.0",
4
4
  "description": "MCP server for PostMD — publish Markdown, get a shareable web page",
5
5
  "mcpName": "io.github.reinlainer/postmd-mcp-server",
6
6
  "type": "module",
@@ -90,6 +90,22 @@ if (docCode) {
90
90
  const meta2 = await callJson(`/documents/${docCode}/meta`);
91
91
  check("password cleared", meta2.json?.data?.hasPassword === false, meta2.text);
92
92
 
93
+ // 5b. 본문을 갈아 끼울 때는 붙어 있는 메모를 어떻게 할지 골라야 한다
94
+ const bodyOnly = new FormData();
95
+ bodyOnly.append("file", new Blob([`# ${MARKER} v2\n\n다른 문장\n`], { type: "text/markdown" }), "v2.md");
96
+ const undecided = await callJson(`/documents/${docCode}/update`, { method: "POST", body: bodyOnly });
97
+ check(
98
+ "replacing content without notesOnReplace is refused",
99
+ undecided.json?.resultCode === "E_DOC_0009",
100
+ undecided.text,
101
+ );
102
+
103
+ const decided = new FormData();
104
+ decided.append("file", new Blob([`# ${MARKER} v2\n\n다른 문장\n`], { type: "text/markdown" }), "v2.md");
105
+ decided.append("notesOnReplace", "keep");
106
+ const replaced = await callJson(`/documents/${docCode}/update`, { method: "POST", body: decided });
107
+ check("replacing content with keep succeeds", replaced.json?.resultCode === "200", replaced.text);
108
+
93
109
  // 6. 그룹 목록에 보이는지
94
110
  if (groupId != null) {
95
111
  const listed = await callJson(`/groups/${groupId}/documents?q=smoke`);
@@ -108,5 +124,50 @@ if (groupId != null) {
108
124
  check("delete group", dropped.json?.resultCode === "200", dropped.text);
109
125
  }
110
126
 
127
+ /*
128
+ 9. 익명 발행과 제어 토큰.
129
+
130
+ 자격 증명을 붙이지 않고 부른다. 키를 실으면 그 회원 소유가 되어 토큰이 나오지 않으므로,
131
+ 여기서만 Authorization 헤더를 뺀다.
132
+ */
133
+ const anonForm = new FormData();
134
+ anonForm.append(
135
+ "file",
136
+ new Blob([`# Smoke anon\n\n${MARKER}\n`], { type: "text/markdown" }),
137
+ "smoke-anon.md"
138
+ );
139
+ const anonRes = await fetch(`${base}/api/v1/documents`, { method: "POST", body: anonForm });
140
+ const anon = await anonRes.json().catch(() => null);
141
+ check("publish without a credential", anon?.resultCode === "200", JSON.stringify(anon));
142
+
143
+ const anonCode = anon?.data?.docCode;
144
+ const controlToken = anon?.data?.controlToken;
145
+ check("answer carries a control token", typeof controlToken === "string" && controlToken.startsWith("pmt_"));
146
+ check("answer carries a deletion date", typeof anon?.data?.retainedUntil === "string");
147
+ check("answer explains the terms", typeof anon?.message === "string" && anon.message.length > 0);
148
+
149
+ if (anonCode && controlToken) {
150
+ const titled = new FormData();
151
+ titled.append("title", `mcp smoke anon ${stamp}`);
152
+ const changed = await fetch(`${base}/api/v1/documents/${anonCode}/update`, {
153
+ method: "POST",
154
+ headers: { "X-Document-Token": controlToken },
155
+ body: titled,
156
+ });
157
+ check("token updates the document", (await changed.json().catch(() => null))?.resultCode === "200");
158
+
159
+ const refused = await fetch(`${base}/api/v1/documents/${anonCode}/delete`, {
160
+ method: "POST",
161
+ headers: { "X-Document-Token": "pmt_wrong" },
162
+ });
163
+ check("a wrong token is refused", (await refused.json().catch(() => null))?.resultCode === "E_DOC_0008");
164
+
165
+ const removed = await fetch(`${base}/api/v1/documents/${anonCode}/delete`, {
166
+ method: "POST",
167
+ headers: { "X-Document-Token": controlToken },
168
+ });
169
+ check("token deletes the document", (await removed.json().catch(() => null))?.resultCode === "200");
170
+ }
171
+
111
172
  console.log(failures ? `\n${failures} failure(s)` : "\nall good");
112
173
  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.1.1",
5
+ "version": "2.3.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.1.1",
14
+ "version": "2.3.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.1.0";
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;
@@ -220,13 +223,68 @@ async function updateDocument(ctx, a, markdownBuffer) {
220
223
  if ([...form.keys()].length === 0) {
221
224
  return textErr("Nothing to update: pass new markdown, or at least one metadata field.");
222
225
  }
226
+ /*
227
+ Replacing the content needs a decision about the notes anchored to it. Checked here so the
228
+ caller reads it as a missing argument rather than an HTTP error from the server.
229
+ */
230
+ if (markdownBuffer !== null) {
231
+ if (a.notesOnReplace !== "keep" && a.notesOnReplace !== "abort") {
232
+ return textErr(
233
+ "notesOnReplace is required when replacing the content: keep or abort. " +
234
+ "Notes are located by the text they quote, so replacing the body moves or loses " +
235
+ "where they point. keep replaces anyway; abort refuses when the document has notes " +
236
+ "anchored to its text.",
237
+ );
238
+ }
239
+ form.append("notesOnReplace", a.notesOnReplace);
240
+ }
223
241
  const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/update`, {
224
242
  method: "POST",
243
+ headers: tokenHeader(a),
225
244
  body: form,
226
245
  });
227
246
  return fromEnvelope(r);
228
247
  }
229
248
 
249
+ /**
250
+ * 익명 발행 문서의 제어 토큰 인자.
251
+ *
252
+ * 키를 대신하는 것이 아니라 그 문서 하나에만 듣는다. 그래서 키가 없어도 이 값이 있으면
253
+ * 도구를 부를 수 있고, 키가 있어도 남의 익명 문서에는 이 값이 있어야 한다.
254
+ */
255
+ const NOTES_ON_REPLACE_PROP = {
256
+ type: "string",
257
+ enum: ["keep", "abort"],
258
+ description:
259
+ "Required when replacing the content. Notes are located by the text they quote, so " +
260
+ "replacing the body moves or loses where they point: a note whose quote is gone loses " +
261
+ "its place in the body, and one whose quote now appears elsewhere points there. " +
262
+ "keep replaces anyway and leaves the notes. abort refuses when the document has notes " +
263
+ "anchored to its text, and the answer says how many.",
264
+ };
265
+
266
+ const CONTROL_TOKEN_PROP = {
267
+ type: "string",
268
+ description:
269
+ "Control token from an anonymous publish answer (pmt_...). Lets you act on that one " +
270
+ "document without an API key.",
271
+ };
272
+
273
+ /** 제어 토큰을 헤더로 옮긴다. 서버는 회원 인증 헤더와 나눠서 받는다. */
274
+ function tokenHeader(a) {
275
+ return a.controlToken ? { "X-Document-Token": String(a.controlToken) } : {};
276
+ }
277
+
278
+ /**
279
+ * 키가 없어도 제어 토큰이 있으면 통과시킨다.
280
+ *
281
+ * 토큰만으로 되는 일에 키를 요구하면, 방금 익명으로 발행하고 토큰을 받은 쪽이 자기 문서를
282
+ * 손대지 못한다.
283
+ */
284
+ function missingKeyUnlessToken(ctx, a, scopes) {
285
+ return a.controlToken ? null : missingKey(ctx, scopes);
286
+ }
287
+
230
288
  /** 문서 메타데이터 공통 속성. 만들기·고치기 스키마가 나눠 쓴다. */
231
289
  const DOC_META_PROPS = {
232
290
  password: { type: "string", description: "Readers must supply this password to see the content." },
@@ -246,8 +304,11 @@ const TOOL_DEFS = [
246
304
  name: "postmd_create_document",
247
305
  description:
248
306
  "Publish Markdown as a PostMD web page. No API key required — anyone can publish. " +
249
- "Returns docCode and data.shareUrl; hand shareUrl to people. With an API key the " +
250
- "document belongs to that member and can be updated later; groupId files it into " +
307
+ "Returns docCode and data.shareUrl; hand shareUrl to people. Without a key the " +
308
+ "document is anonymous: data.retainedUntil is when it is deleted and " +
309
+ "data.controlToken is the only way to update or delete it, shown once and never " +
310
+ "reissued — report both to the person. With an API key the document belongs to that " +
311
+ "member, has no expiry, needs no token and can collect notes; groupId files it into " +
251
312
  "that group instead of the default one (key with documents:write).",
252
313
  inputSchema: {
253
314
  type: "object",
@@ -271,7 +332,8 @@ const TOOL_DEFS = [
271
332
  name: "postmd_create_document_from_file",
272
333
  description:
273
334
  "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.",
335
+ "running this MCP server — use it for large files instead of pasting the body. " +
336
+ "Without an API key it returns data.controlToken and data.retainedUntil, same as above.",
275
337
  inputSchema: {
276
338
  type: "object",
277
339
  properties: {
@@ -338,9 +400,11 @@ const TOOL_DEFS = [
338
400
  {
339
401
  name: "postmd_update_document",
340
402
  description:
341
- "Update a document you own. Requires an API key with documents:write. Include " +
342
- "`markdown` to replace the stored content; any metadata field replaces that field. " +
343
- "clearPassword / clearShareEndDate remove the password / end date.",
403
+ "Update a document. Requires an API key with documents:write for a document you own, " +
404
+ "or `controlToken` for an anonymously published one. Include `markdown` to replace " +
405
+ "the stored content; any metadata field replaces that field. clearPassword / " +
406
+ "clearShareEndDate remove the password / end date. Updating does not push back the " +
407
+ "deletion date of an anonymous document. Replacing the content requires notesOnReplace.",
344
408
  inputSchema: {
345
409
  type: "object",
346
410
  properties: {
@@ -354,6 +418,8 @@ const TOOL_DEFS = [
354
418
  ...DOC_META_PROPS,
355
419
  clearPassword: { type: "boolean", description: "true removes the password." },
356
420
  clearShareEndDate: { type: "boolean", description: "true removes the end date, making sharing open-ended." },
421
+ notesOnReplace: NOTES_ON_REPLACE_PROP,
422
+ controlToken: CONTROL_TOKEN_PROP,
357
423
  },
358
424
  required: ["docCode"],
359
425
  },
@@ -362,7 +428,7 @@ const TOOL_DEFS = [
362
428
  name: "postmd_update_document_from_file",
363
429
  description:
364
430
  "Same as postmd_update_document, but reads the new Markdown from filePath on the " +
365
- "machine running this MCP server.",
431
+ "machine running this MCP server. Takes `controlToken` the same way.",
366
432
  inputSchema: {
367
433
  type: "object",
368
434
  properties: {
@@ -376,18 +442,22 @@ const TOOL_DEFS = [
376
442
  ...DOC_META_PROPS,
377
443
  clearPassword: { type: "boolean", description: "true removes the password." },
378
444
  clearShareEndDate: { type: "boolean", description: "true removes the end date, making sharing open-ended." },
445
+ notesOnReplace: NOTES_ON_REPLACE_PROP,
446
+ controlToken: CONTROL_TOKEN_PROP,
379
447
  },
380
- required: ["docCode", "filePath"],
448
+ required: ["docCode", "filePath", "notesOnReplace"],
381
449
  },
382
450
  },
383
451
  {
384
452
  name: "postmd_delete_document",
385
453
  description:
386
- "Delete a document you own (recoverable for 30 days, then purged). Requires an API " +
387
- "key with documents:write.",
454
+ "Delete a document. Requires an API key with documents:write for a document you own, " +
455
+ "or `controlToken` for an anonymously published one. There is no endpoint to undo " +
456
+ "this: the document stops being served at once and its stored content is erased about " +
457
+ "a month later.",
388
458
  inputSchema: {
389
459
  type: "object",
390
- properties: { docCode: { type: "string" } },
460
+ properties: { docCode: { type: "string" }, controlToken: CONTROL_TOKEN_PROP },
391
461
  required: ["docCode"],
392
462
  },
393
463
  },
@@ -432,9 +502,10 @@ const TOOL_DEFS = [
432
502
  description:
433
503
  "Attach a note or highlight to a document. Requires an API key with documents:write. " +
434
504
  "Give `content` for a note, `color` alone for a colour-only highlight (then " +
435
- "`quotedContent` is required — a highlight must point at a passage). scope PRIVATE " +
436
- "(default) is visible only to the key's member; SHARED is visible to every reader " +
437
- "and only allowed on documents owned by a person.",
505
+ "`quotedContent` is required — a highlight must point at a passage). Visibility comes " +
506
+ "from ownership: on the key member's own document choose PRIVATE (only they see it) or " +
507
+ "SHARED; on anyone else's document every note is SHARED, so omit scope. Documents " +
508
+ "nobody owns — anonymous uploads and service-owned pages — take no notes at all.",
438
509
  inputSchema: {
439
510
  type: "object",
440
511
  properties: {
@@ -456,7 +527,8 @@ const TOOL_DEFS = [
456
527
  name: "postmd_update_note",
457
528
  description:
458
529
  "Edit a note you wrote. Requires an API key with documents:write. Omitting scope " +
459
- "keeps the current one. The note must keep text or a colour.",
530
+ "keeps the current one; a scope you do send follows the ownership rule above. The " +
531
+ "note must keep text or a colour.",
460
532
  inputSchema: {
461
533
  type: "object",
462
534
  properties: {
@@ -664,12 +736,12 @@ async function runTool(ctx, name, args) {
664
736
  }
665
737
  }
666
738
  case "postmd_update_document": {
667
- const denied = missingKey(ctx, "documents:write");
739
+ const denied = missingKeyUnlessToken(ctx, a, "documents:write");
668
740
  if (denied) return denied;
669
741
  return await updateDocument(ctx, a, a.markdown ?? null);
670
742
  }
671
743
  case "postmd_update_document_from_file": {
672
- const denied = missingKey(ctx, "documents:write");
744
+ const denied = missingKeyUnlessToken(ctx, a, "documents:write");
673
745
  if (denied) return denied;
674
746
  try {
675
747
  const { buffer, suggestedName } = await readLocalFile(a.filePath);
@@ -679,10 +751,11 @@ async function runTool(ctx, name, args) {
679
751
  }
680
752
  }
681
753
  case "postmd_delete_document": {
682
- const denied = missingKey(ctx, "documents:write");
754
+ const denied = missingKeyUnlessToken(ctx, a, "documents:write");
683
755
  if (denied) return denied;
684
756
  const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/delete`, {
685
757
  method: "POST",
758
+ headers: tokenHeader(a),
686
759
  });
687
760
  return fromEnvelope(r);
688
761
  }