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 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
  |------|---------|
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postmd-mcp-server",
3
- "version": "2.1.1",
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.1",
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.1",
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;
@@ -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. With an API key the " +
250
- "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 " +
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 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.",
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 you own (recoverable for 30 days, then purged). Requires an API " +
387
- "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.",
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). 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.",
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. 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.",
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 = missingKey(ctx, "documents:write");
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 = missingKey(ctx, "documents:write");
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 = missingKey(ctx, "documents:write");
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
  }