postmd-mcp-server 2.1.0 → 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 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 (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
 
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. `PRIVATE` notes belong to the key's member; `SHARED` notes are
51
- comments every reader sees:
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
  |------|---------|
@@ -73,19 +78,21 @@ For uploads: either pass the full Markdown as the `markdown` argument, or pass a
73
78
 
74
79
  ## Quickstart
75
80
 
81
+ Nothing to install. `npx` fetches the package and the MCP client spawns it.
82
+
76
83
  ```bash
77
- git clone https://github.com/reinlainer/postmd-mcp-server.git
78
- cd postmd-mcp-server
79
- npm ci
80
- node src/index.js # normally spawned by the MCP client; use for debugging
84
+ npx -y postmd-mcp-server
81
85
  ```
82
86
 
87
+ Run it by hand only to check that it starts — it speaks MCP over stdin and stdout, so it
88
+ will sit there waiting for a client.
89
+
83
90
  ## Client configuration
84
91
 
85
92
  Claude Code:
86
93
 
87
94
  ```bash
88
- claude mcp add postmd -- node /absolute/path/to/postmd-mcp-server/src/index.js
95
+ claude mcp add postmd -- npx -y postmd-mcp-server
89
96
  ```
90
97
 
91
98
  Cursor (`~/.cursor/mcp.json`) and most other stdio clients:
@@ -95,14 +102,23 @@ Cursor (`~/.cursor/mcp.json`) and most other stdio clients:
95
102
  "mcpServers": {
96
103
  "PostMD": {
97
104
  "type": "stdio",
98
- "command": "node",
99
- "args": ["/absolute/path/to/postmd-mcp-server/src/index.js"],
105
+ "command": "npx",
106
+ "args": ["-y", "postmd-mcp-server"],
100
107
  "env": { "POSTMD_API_KEY": "pmk_…" }
101
108
  }
102
109
  }
103
110
  }
104
111
  ```
105
112
 
113
+ To run a checkout instead — changing the code, or debugging against a local PostMD —
114
+ point the client at the file.
115
+
116
+ ```bash
117
+ git clone https://github.com/reinlainer/postmd-mcp-server.git
118
+ cd postmd-mcp-server && npm ci
119
+ claude mcp add postmd-dev -- node "$PWD/src/index.js"
120
+ ```
121
+
106
122
  Leave `env` out entirely for publish/read-only use. `cp .env.example .env` works too — the server loads its own `.env`.
107
123
 
108
124
  ## Smoke test
@@ -114,7 +130,7 @@ export POSTMD_API_KEY=pmk_…
114
130
  npm run smoke
115
131
  ```
116
132
 
117
- 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.
118
134
 
119
135
  ## Stack
120
136
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postmd-mcp-server",
3
- "version": "2.1.0",
3
+ "version": "2.2.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",
@@ -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.1.0",
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.1.0",
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.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;
@@ -176,9 +179,18 @@ const ATTACHMENT_MIME = {
176
179
  pdf: "application/pdf",
177
180
  };
178
181
 
179
- /** 만든 문서에는 나눠 줄 주소를 붙여 준다. 에이전트의 다음 행동이 바로 그것이다. */
182
+ /**
183
+ * 만든 문서에는 나눠 줄 주소를 붙여 준다. 에이전트의 다음 행동이 바로 그것이다.
184
+ *
185
+ * 서버가 이미 `shareUrl` 을 돌려주면 그것을 쓴다. 서버는 요청이 실제로 들어온 주소를
186
+ * 보고 만들고, 여기서는 `POSTMD_BASE_URL` 을 보고 만든다. 자체 호스팅에서 그 둘이
187
+ * 다르면 값이 갈리는데, 사람에게 건너가는 주소는 서버가 아는 쪽이 맞다.
188
+ *
189
+ * 그래도 이 함수를 남겨 둔다. `shareUrl` 을 돌려주지 않는 구 버전 서버를 가리키는
190
+ * 설정이 있을 수 있고, 그때도 에이전트는 건넬 주소를 받아야 한다.
191
+ */
180
192
  function addShareUrl(ctx, data) {
181
- if (data && typeof data.docCode === "string" && data.docCode) {
193
+ if (data && typeof data.docCode === "string" && data.docCode && !data.shareUrl) {
182
194
  data.shareUrl = `${ctx.base}/share/${encodeURIComponent(data.docCode)}`;
183
195
  }
184
196
  }
@@ -213,11 +225,40 @@ async function updateDocument(ctx, a, markdownBuffer) {
213
225
  }
214
226
  const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/update`, {
215
227
  method: "POST",
228
+ headers: tokenHeader(a),
216
229
  body: form,
217
230
  });
218
231
  return fromEnvelope(r);
219
232
  }
220
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
+
221
262
  /** 문서 메타데이터 공통 속성. 만들기·고치기 스키마가 나눠 쓴다. */
222
263
  const DOC_META_PROPS = {
223
264
  password: { type: "string", description: "Readers must supply this password to see the content." },
@@ -237,8 +278,11 @@ const TOOL_DEFS = [
237
278
  name: "postmd_create_document",
238
279
  description:
239
280
  "Publish Markdown as a PostMD web page. No API key required — anyone can publish. " +
240
- "Returns docCode and data.shareUrl; hand shareUrl to people. With an API key the " +
241
- "document belongs to that member and can be updated later; groupId files it into " +
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 " +
242
286
  "that group instead of the default one (key with documents:write).",
243
287
  inputSchema: {
244
288
  type: "object",
@@ -262,7 +306,8 @@ const TOOL_DEFS = [
262
306
  name: "postmd_create_document_from_file",
263
307
  description:
264
308
  "Same as postmd_create_document, but reads the Markdown from filePath on the machine " +
265
- "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.",
266
311
  inputSchema: {
267
312
  type: "object",
268
313
  properties: {
@@ -329,9 +374,11 @@ const TOOL_DEFS = [
329
374
  {
330
375
  name: "postmd_update_document",
331
376
  description:
332
- "Update a document you own. Requires an API key with documents:write. Include " +
333
- "`markdown` to replace the stored content; any metadata field replaces that field. " +
334
- "clearPassword / clearShareEndDate remove the password / end date.",
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.",
335
382
  inputSchema: {
336
383
  type: "object",
337
384
  properties: {
@@ -345,6 +392,7 @@ const TOOL_DEFS = [
345
392
  ...DOC_META_PROPS,
346
393
  clearPassword: { type: "boolean", description: "true removes the password." },
347
394
  clearShareEndDate: { type: "boolean", description: "true removes the end date, making sharing open-ended." },
395
+ controlToken: CONTROL_TOKEN_PROP,
348
396
  },
349
397
  required: ["docCode"],
350
398
  },
@@ -353,7 +401,7 @@ const TOOL_DEFS = [
353
401
  name: "postmd_update_document_from_file",
354
402
  description:
355
403
  "Same as postmd_update_document, but reads the new Markdown from filePath on the " +
356
- "machine running this MCP server.",
404
+ "machine running this MCP server. Takes `controlToken` the same way.",
357
405
  inputSchema: {
358
406
  type: "object",
359
407
  properties: {
@@ -367,6 +415,7 @@ const TOOL_DEFS = [
367
415
  ...DOC_META_PROPS,
368
416
  clearPassword: { type: "boolean", description: "true removes the password." },
369
417
  clearShareEndDate: { type: "boolean", description: "true removes the end date, making sharing open-ended." },
418
+ controlToken: CONTROL_TOKEN_PROP,
370
419
  },
371
420
  required: ["docCode", "filePath"],
372
421
  },
@@ -374,11 +423,13 @@ const TOOL_DEFS = [
374
423
  {
375
424
  name: "postmd_delete_document",
376
425
  description:
377
- "Delete a document you own (recoverable for 30 days, then purged). Requires an API " +
378
- "key with documents:write.",
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.",
379
430
  inputSchema: {
380
431
  type: "object",
381
- properties: { docCode: { type: "string" } },
432
+ properties: { docCode: { type: "string" }, controlToken: CONTROL_TOKEN_PROP },
382
433
  required: ["docCode"],
383
434
  },
384
435
  },
@@ -423,9 +474,10 @@ const TOOL_DEFS = [
423
474
  description:
424
475
  "Attach a note or highlight to a document. Requires an API key with documents:write. " +
425
476
  "Give `content` for a note, `color` alone for a colour-only highlight (then " +
426
- "`quotedContent` is required — a highlight must point at a passage). scope PRIVATE " +
427
- "(default) is visible only to the key's member; SHARED is visible to every reader " +
428
- "and only allowed on documents owned by a person.",
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.",
429
481
  inputSchema: {
430
482
  type: "object",
431
483
  properties: {
@@ -447,7 +499,8 @@ const TOOL_DEFS = [
447
499
  name: "postmd_update_note",
448
500
  description:
449
501
  "Edit a note you wrote. Requires an API key with documents:write. Omitting scope " +
450
- "keeps the current one. The note must keep text or a colour.",
502
+ "keeps the current one; a scope you do send follows the ownership rule above. The " +
503
+ "note must keep text or a colour.",
451
504
  inputSchema: {
452
505
  type: "object",
453
506
  properties: {
@@ -655,12 +708,12 @@ async function runTool(ctx, name, args) {
655
708
  }
656
709
  }
657
710
  case "postmd_update_document": {
658
- const denied = missingKey(ctx, "documents:write");
711
+ const denied = missingKeyUnlessToken(ctx, a, "documents:write");
659
712
  if (denied) return denied;
660
713
  return await updateDocument(ctx, a, a.markdown ?? null);
661
714
  }
662
715
  case "postmd_update_document_from_file": {
663
- const denied = missingKey(ctx, "documents:write");
716
+ const denied = missingKeyUnlessToken(ctx, a, "documents:write");
664
717
  if (denied) return denied;
665
718
  try {
666
719
  const { buffer, suggestedName } = await readLocalFile(a.filePath);
@@ -670,10 +723,11 @@ async function runTool(ctx, name, args) {
670
723
  }
671
724
  }
672
725
  case "postmd_delete_document": {
673
- const denied = missingKey(ctx, "documents:write");
726
+ const denied = missingKeyUnlessToken(ctx, a, "documents:write");
674
727
  if (denied) return denied;
675
728
  const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/delete`, {
676
729
  method: "POST",
730
+ headers: tokenHeader(a),
677
731
  });
678
732
  return fromEnvelope(r);
679
733
  }