postmd-mcp-server 2.4.0 → 2.6.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.
@@ -74,7 +74,34 @@ jobs:
74
74
  run: |
75
75
  curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
76
76
 
77
+ # 레지스트리는 소유권을 npm 으로 확인한다. 방금 올린 판의 메타데이터를 받아 mcpName 이
78
+ # 서버 이름과 같은지 본다. 그런데 npm 에 올린 직후에는 그 버전이 아직 보이지 않아
79
+ # 곧바로 부르면 404 로 떨어진다. 2.4.0 이 실제로 이렇게 실패했다 - npm 에는 올라갔는데
80
+ # 레지스트리만 빠져 두 곳이 어긋났다. 보일 때까지 기다린 다음 넘어간다.
81
+ - name: Wait until npm serves this version
82
+ run: |
83
+ NAME=$(node -p "require('./package.json').name")
84
+ VERSION=$(node -p "require('./package.json').version")
85
+ for i in $(seq 1 30); do
86
+ MCP_NAME=$(curl -fsS "https://registry.npmjs.org/$NAME/$VERSION" 2>/dev/null \
87
+ | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{process.stdout.write(JSON.parse(s).mcpName||'')}catch(e){}})")
88
+ if [ -n "$MCP_NAME" ]; then
89
+ echo "npm serves $NAME@$VERSION with mcpName $MCP_NAME"
90
+ exit 0
91
+ fi
92
+ echo "attempt $i/30: not visible yet"
93
+ sleep 10
94
+ done
95
+ echo "::error::npm did not serve $NAME@$VERSION within 5 minutes"
96
+ exit 1
97
+
98
+ # 기다린 뒤에도 순간적으로 실패할 수 있다. 같은 버전을 다시 올리는 것은 문제가 없다.
77
99
  - name: Publish to the MCP registry
78
100
  run: |
79
101
  ./mcp-publisher login github-oidc
80
- ./mcp-publisher publish
102
+ for i in 1 2 3; do
103
+ if ./mcp-publisher publish; then exit 0; fi
104
+ echo "publish failed ($i/3), retrying in 20s"
105
+ sleep 20
106
+ done
107
+ exit 1
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # PostMD MCP Server
2
2
 
3
- [Model Context Protocol](https://modelcontextprotocol.io) server for **[PostMD](https://postmd.turink.com)** — publish a Markdown document, get a web page you share by link. Optional groups, document passwords, share expiry and viewer themes. This server wraps PostMD's public API (`/api/v1`) so assistants can publish, read, update and organize documents.
3
+ [Model Context Protocol](https://modelcontextprotocol.io) server for **[PostMD](https://postmd.turink.com)** — publish a Markdown document, get a web page you share by link, and add a document graph that the viewer draws: the reading order, the bodies and rules a document names, a procedure spread over its chapters. Optional groups, document passwords and viewer themes. This server wraps PostMD's public API (`/api/v1`) so assistants can publish, read, update and organize documents.
4
4
 
5
- **Publishing needs no account and no key.** With zero configuration this server can already turn Markdown into a shareable page, and the hosted server at `https://postmd.turink.com/mcp` needs no install either. An API key adds management: updating and deleting your documents, attachments, and groups.
5
+ **Publishing needs no account and no key.** With zero configuration this server can already turn Markdown into a shareable page, and the hosted server at `https://postmd.turink.com/mcp` needs no install either. An API key adds management: organizing documents in groups, and notes.
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.
7
+ **30-day retention.** Documents have a 30-day retention period (`data.retainedUntil`) that extends by 30 days whenever the document is read (at most once per day). Documents without a password can be updated or deleted by anyone; password-protected documents require the password or the owner's API key.
8
8
 
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)
9
+ **Where things are written down:** [/llms.txt](https://postmd.turink.com/llms.txt) lists what the service can do and which page answers each thing; it is the place to start. [/docs/api](https://postmd.turink.com/docs/api) is the HTTP reference and [/api-docs](https://postmd.turink.com/api-docs) the machine-readable spec.
10
10
 
11
11
  ## Hosted or local
12
12
 
@@ -14,13 +14,12 @@
14
14
  |---|---|---|
15
15
  | Address | `https://postmd.turink.com/mcp` | `npx -y postmd-mcp-server` |
16
16
  | Needs | nothing | Node.js 20 or later |
17
- | Tools | 5 | all 21 |
18
- | API key | not accepted | optional, for the management tools |
17
+ | Tools | 5 | all 20 |
18
+ | API key | not accepted | optional, for member-scoped tools |
19
19
  | Clients | any, including web-only ones such as ChatGPT and claude.ai | any that can run a local process |
20
20
 
21
21
  The hosted server has no way to receive an API key, so it carries only the tools that need
22
- none. Attachments, groups, notes and the tools that read a file from your disk are
23
- local-only.
22
+ none. Groups, notes and the tools that read a file from your disk are local-only.
24
23
 
25
24
  ## Configuration
26
25
 
@@ -37,8 +36,7 @@ Load order: this repo's `.env` (if present) is applied via `dotenv` without over
37
36
  ## Tools
38
37
 
39
38
  Every tool below works on the local server. The hosted server carries five of them:
40
- `postmd_create_document`, `postmd_get_document`, `postmd_get_document_raw`, and — with a
41
- `controlToken` instead of a key — `postmd_update_document` and `postmd_delete_document`.
39
+ `postmd_create_document`, `postmd_get_document`, `postmd_get_document_raw`, `postmd_update_document`, and `postmd_delete_document`.
42
40
 
43
41
  Publishing and reading — no key needed:
44
42
 
@@ -49,18 +47,29 @@ Publishing and reading — no key needed:
49
47
  | `postmd_get_document` | Metadata by `docCode` |
50
48
  | `postmd_get_document_raw` | Stored Markdown body (optional `password`) |
51
49
 
52
- Managing documents — key with `documents:write`:
53
-
54
- Each of the first three also accepts `controlToken` instead of a key, for a document published anonymously.
50
+ Managing documents — key with `documents:write` (or password / no credential for unowned documents without password):
55
51
 
56
52
  | Tool | Purpose |
57
53
  |------|---------|
58
- | `postmd_update_document` | Replace content and/or metadata; can clear password / end date |
54
+ | `postmd_update_document` | Replace content and/or metadata; can clear password |
59
55
  | `postmd_update_document_from_file` | Same, body read from a local `filePath` |
60
56
  | `postmd_delete_document` | Delete a document (no undo) |
61
- | `postmd_upload_attachment` | Upload an image/PDF, get a URL to embed in Markdown |
62
57
  | `postmd_create_documents_from_files` | Bulk-publish several `.md` files in one call |
63
- | `postmd_move_document_to_group` | Move a document into a group / folder |
58
+ | `postmd_move_document_to_group` | Move a document into a group |
59
+
60
+ ### Graphs
61
+
62
+ A PostMD document can carry graph data that the viewer draws, showing
63
+ how the parts of the document relate. No tool here creates or edits a graph, because there is no
64
+ endpoint for one: the data sits in the Markdown as an HTML comment and travels with the body.
65
+
66
+ Adding a graph to an existing document therefore means reading it with `postmd_get_document_raw`,
67
+ inserting the comment, and sending the whole body back with `postmd_update_document`. Publishing a
68
+ new document with a graph is an ordinary `postmd_create_document` call.
69
+
70
+ The format is at <https://postmd.turink.com/docs/graph>. Working out what the nodes are and how
71
+ they connect requires reading the document, which is the calling agent's part; PostMD only draws
72
+ what it finds.
64
73
 
65
74
  ### Replacing content on a document that has notes
66
75
 
@@ -100,7 +109,7 @@ Groups — key with `groups:read` / `groups:write`:
100
109
  | `postmd_list_groups` | Groups visible to the key (paged) |
101
110
  | `postmd_list_group_documents` | Documents in a group (paged, searchable, sortable) |
102
111
  | `postmd_create_group` | New group |
103
- | `postmd_update_group` | Rename / change expiry |
112
+ | `postmd_update_group` | Rename group |
104
113
  | `postmd_delete_group` | Delete a group (documents survive) |
105
114
 
106
115
  For uploads: either pass the full Markdown as the `markdown` argument, or pass a local `filePath` only so this server reads the file. The path must exist on the machine running the MCP server.
@@ -179,7 +188,7 @@ export POSTMD_API_KEY=pmk_…
179
188
  npm run smoke
180
189
  ```
181
190
 
182
- 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.
191
+ 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 without a token.
183
192
 
184
193
  ## Stack
185
194
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "postmd-mcp-server",
3
- "version": "2.4.0",
4
- "description": "MCP server for PostMD — publish Markdown, get a shareable web page",
3
+ "version": "2.6.0",
4
+ "description": "MCP server for PostMD — publish Markdown as a shareable web page, with document graphs",
5
5
  "mcpName": "io.github.reinlainer/postmd-mcp-server",
6
6
  "type": "module",
7
7
  "main": "src/index.js",
@@ -25,7 +25,8 @@
25
25
  "mcp",
26
26
  "postmd",
27
27
  "markdown",
28
- "publishing"
28
+ "publishing",
29
+ "document-graph"
29
30
  ],
30
31
  "license": "MIT",
31
32
  "dependencies": {
@@ -125,10 +125,9 @@ if (groupId != null) {
125
125
  }
126
126
 
127
127
  /*
128
- 9. 익명 발행과 제어 토큰.
128
+ 9. 익명 발행 (비밀번호 없는 문서).
129
129
 
130
- 자격 증명을 붙이지 않고 부른다. 키를 실으면 그 회원 소유가 되어 토큰이 나오지 않으므로,
131
- 여기서만 Authorization 헤더를 뺀다.
130
+ 자격 증명을 붙이지 않고 부른다.
132
131
  */
133
132
  const anonForm = new FormData();
134
133
  anonForm.append(
@@ -141,32 +140,22 @@ const anon = await anonRes.json().catch(() => null);
141
140
  check("publish without a credential", anon?.resultCode === "200", JSON.stringify(anon));
142
141
 
143
142
  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");
143
+ check("answer carries a retention date", typeof anon?.data?.retainedUntil === "string");
147
144
  check("answer explains the terms", typeof anon?.message === "string" && anon.message.length > 0);
148
145
 
149
- if (anonCode && controlToken) {
146
+ if (anonCode) {
150
147
  const titled = new FormData();
151
148
  titled.append("title", `mcp smoke anon ${stamp}`);
152
149
  const changed = await fetch(`${base}/api/v1/documents/${anonCode}/update`, {
153
150
  method: "POST",
154
- headers: { "X-Document-Token": controlToken },
155
151
  body: titled,
156
152
  });
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");
153
+ check("anyone can update a document without password", (await changed.json().catch(() => null))?.resultCode === "200");
164
154
 
165
155
  const removed = await fetch(`${base}/api/v1/documents/${anonCode}/delete`, {
166
156
  method: "POST",
167
- headers: { "X-Document-Token": controlToken },
168
157
  });
169
- check("token deletes the document", (await removed.json().catch(() => null))?.resultCode === "200");
158
+ check("anyone can delete a document without password", (await removed.json().catch(() => null))?.resultCode === "200");
170
159
  }
171
160
 
172
161
  console.log(failures ? `\n${failures} failure(s)` : "\nall good");
package/server.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.reinlainer/postmd-mcp-server",
4
- "description": "Publish Markdown to PostMD and get a shareable web page",
5
- "version": "2.4.0",
4
+ "description": "Publish Markdown as a shareable web page, with document graphs",
5
+ "version": "2.6.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.4.0",
14
+ "version": "2.6.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -24,7 +24,7 @@
24
24
  },
25
25
  {
26
26
  "name": "POSTMD_API_KEY",
27
- "description": "Needed only for managing documents, attachments and groups. Publishing and reading work without it.",
27
+ "description": "Needed only for managing documents and groups. Publishing and reading work without it.",
28
28
  "isRequired": false,
29
29
  "isSecret": true
30
30
  },
package/src/server.js CHANGED
@@ -6,9 +6,10 @@
6
6
  * 전송에 따라 달라지는 것은 어느 도구를 여느냐 하나뿐이다(REMOTE_TOOLS).
7
7
  *
8
8
  * 필수 환경변수는 없다. POSTMD_BASE_URL 이 없으면 운영(https://postmd.turink.com)을
9
- * 부르고, POSTMD_API_KEY 가 없으면 발행과 읽기만 할 수 있다 — 그것만으로도 PostMD 의
10
- * 기본 쓰임은 다 된다. 문서 관리(수정·삭제)·첨부·그룹에는 키가 필요하며, 키 없이
11
- * 그런 도구를 부르면 어느 스코프가 왜 필요한지 알려 준다.
9
+ * 부른다. POSTMD_API_KEY 가 없어도 문서 한 건의 발행·조회·수정·삭제는 다 된다 — 비밀번호가
10
+ * 없는 문서는 누구나 고치고 지울 수 있고, 걸린 문서는 그 비밀번호를 받는다. 키는 여러 파일을
11
+ * 한꺼번에 올리거나 메모와 그룹을 다룰 때 필요하며, 키 없이 그런 도구를 부르면 어느 스코프가
12
+ * 필요한지 알려 준다.
12
13
  *
13
14
  * 도구 설명과 오류 문구는 영어다. 이 문장들은 사람이 아니라 에이전트가 읽는다.
14
15
  */
@@ -32,13 +33,11 @@ const DEFAULT_BASE_URL = "https://postmd.turink.com";
32
33
  /** initialize 때 클라이언트에 전달되어, 모델이 도구를 고르기 전에 읽는다. */
33
34
  const INSTRUCTIONS_LOCAL =
34
35
  "PostMD publishes Markdown as web pages. Use the postmd_* tools instead of calling " +
35
- "the HTTP API directly. Creating a document needs no API key; updating, deleting, " +
36
- "attachments and groups need POSTMD_API_KEY with the matching scope. Pass the full " +
37
- "Markdown in `markdown`, or pass a local `filePath` so this server reads the file " +
38
- "itself. A successful create returns data.shareUrl — hand that URL to people. " +
39
- "Creating without a key also returns data.controlToken and data.retainedUntil: the " +
40
- "document is deleted at that instant, and the token is the only way to update or " +
41
- "delete it. It is shown once, so report it to the person along with the URL.";
36
+ "the HTTP API directly. Documents are kept for 30 days from publication or their last read. " +
37
+ "Documents without a password can be updated or deleted by anyone; password-protected " +
38
+ "documents require the password or the owner's API key. Pass the full Markdown in " +
39
+ "`markdown`, or pass a local `filePath` so this server reads the file itself. A successful " +
40
+ "create returns data.shareUrl — hand that URL to people. What else the service can do, and which page describes it, is listed at https://postmd.turink.com/llms.txt — including graph data, which is carried inside the Markdown rather than through a tool.";
42
41
 
43
42
  /**
44
43
  * 원격에는 키를 건네줄 길이 없고 서버 기계에 사용자의 파일도 없다. 그래서 그 둘을 말하지
@@ -46,22 +45,21 @@ const INSTRUCTIONS_LOCAL =
46
45
  */
47
46
  const INSTRUCTIONS_REMOTE =
48
47
  "PostMD publishes Markdown as web pages. Use the postmd_* tools instead of calling " +
49
- "the HTTP API directly. Nothing here needs an account, a sign-in or an API key. Pass " +
50
- "the full Markdown in `markdown`. A successful create returns data.shareUrl - hand " +
51
- "that URL to people. It also returns data.controlToken and data.retainedUntil: the " +
52
- "document is deleted at that instant, and the token is the only way to update or " +
53
- "delete it before then. It is shown once and cannot be reissued, so report it to the " +
54
- "person along with the URL, and pass it back as `controlToken` to update or delete.";
48
+ "the HTTP API directly. Nothing here needs an account, a sign-in or an API key. " +
49
+ "Documents are kept for 30 days from publication or their last read. Documents without a " +
50
+ "password can be updated or deleted by anyone. If a document has a password, provide it to " +
51
+ "update or delete. Pass the full Markdown in `markdown`. A successful create returns " +
52
+ "data.shareUrl - hand that URL to people. What else the service can do, and which page describes it, is listed at https://postmd.turink.com/llms.txt - including graph data, which is carried inside the Markdown rather than through a tool.";
55
53
 
56
54
  /**
57
- * 원격에서 여는 도구. 자격 증명 없이 끝까지 가는 것만 남겼다 - 발행과 조회, 그리고 발행할
58
- * 때 받은 제어 토큰으로 하는 수정·삭제다.
55
+ * 원격에서 여는 도구. 자격 증명 없이 끝까지 가는 것만 남겼다 - 발행과 조회, 수정·삭제다.
56
+ * 비밀번호가 없는 문서는 누구나 고치고 지울 수 있고, 걸린 문서는 그 비밀번호를 받는다.
59
57
  *
60
58
  * 뺀 것은 두 부류다. API 키를 요구하는 도구는 원격에 키를 줄 길이 없어 부르면 반드시
61
59
  * 실패하고, `filePath` 를 받는 도구는 그 파일이 이 서버가 도는 기계에 없다.
62
60
  *
63
61
  * 목록에 적은 것만 열린다. 새 도구가 생겨도 여기 이름을 적기 전에는 원격으로 나가지
64
- * 않는다 - 반대로 두면 키가 필요한 도구가 조용히 딸려 나간다.
62
+ * 않는다. 반대로 두면 키가 필요한 도구가 의도치 않게 외부에 노출된다.
65
63
  */
66
64
  const REMOTE_TOOLS = new Set([
67
65
  "postmd_create_document",
@@ -135,16 +133,14 @@ function textErr(message) {
135
133
  * 키가 필요한 도구의 문지기. 키가 없으면 네트워크에 나가지 않고 여기서 알려 준다.
136
134
  *
137
135
  * 원격에는 키를 건네줄 길 자체가 없다. 그 자리에서 환경변수를 설정하라고 하면 부르는 쪽이
138
- * 할 수 없는 일을 시키는 것이므로, 대신 발행할 때 받은 제어 토큰을 가리킨다.
136
+ * 할 수 없는 일을 시키는 것이므로, 대신 키를 넣어 로컬 서버를 띄우는 길을 가리킨다.
139
137
  */
140
138
  function missingKey(ctx, scopes) {
141
139
  if (ctx.key) return null;
142
140
  if (ctx.remote) {
143
141
  return textErr(
144
- "This server has no way to receive an API key. Pass `controlToken` instead — the " +
145
- "token returned when the document was published, which is what an anonymous " +
146
- "publisher uses to change or remove it. For the key-based tools, run the local " +
147
- "server: npx -y postmd-mcp-server, with POSTMD_API_KEY set."
142
+ "This tool requires an API key, which cannot be passed to the remote server. " +
143
+ "Run the local server: npx -y postmd-mcp-server, with POSTMD_API_KEY set."
148
144
  );
149
145
  }
150
146
  return textErr(
@@ -218,18 +214,6 @@ async function readLocalFile(filePath) {
218
214
  return { buffer: await fs.readFile(resolved), suggestedName: path.basename(resolved) };
219
215
  }
220
216
 
221
- /** 첨부는 서버가 확장자로 받아 준다. Content-Type 은 예의상 맞춰 보낸다. */
222
- const ATTACHMENT_MIME = {
223
- png: "image/png",
224
- jpg: "image/jpeg",
225
- jpeg: "image/jpeg",
226
- gif: "image/gif",
227
- webp: "image/webp",
228
- svg: "image/svg+xml",
229
- bmp: "image/bmp",
230
- pdf: "application/pdf",
231
- };
232
-
233
217
  /**
234
218
  * 만든 문서에는 나눠 줄 주소를 붙여 준다. 에이전트의 다음 행동이 바로 그것이다.
235
219
  *
@@ -254,7 +238,6 @@ function documentForm(a, markdownBuffer) {
254
238
  }
255
239
  if (a.title != null) form.append("title", String(a.title));
256
240
  if (a.password != null) form.append("password", String(a.password));
257
- if (a.shareEndDate != null) form.append("shareEndDate", String(a.shareEndDate));
258
241
  if (a.viewerStyle != null) form.append("viewerStyle", String(a.viewerStyle));
259
242
  return form;
260
243
  }
@@ -267,10 +250,14 @@ async function createDocument(ctx, a, markdownBuffer) {
267
250
  return fromEnvelope(r);
268
251
  }
269
252
 
253
+ function passwordHeader(a) {
254
+ const pwd = a.currentPassword ?? a.password;
255
+ return pwd ? { "X-Document-Password": String(pwd) } : {};
256
+ }
257
+
270
258
  async function updateDocument(ctx, a, markdownBuffer) {
271
259
  const form = documentForm(a, markdownBuffer);
272
260
  if (a.clearPassword === true) form.append("clearPassword", "true");
273
- if (a.clearShareEndDate === true) form.append("clearShareEndDate", "true");
274
261
  if ([...form.keys()].length === 0) {
275
262
  return textErr("Nothing to update: pass new markdown, or at least one metadata field.");
276
263
  }
@@ -291,18 +278,12 @@ async function updateDocument(ctx, a, markdownBuffer) {
291
278
  }
292
279
  const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/update`, {
293
280
  method: "POST",
294
- headers: tokenHeader(a),
281
+ headers: passwordHeader(a),
295
282
  body: form,
296
283
  });
297
284
  return fromEnvelope(r);
298
285
  }
299
286
 
300
- /**
301
- * 익명 발행 문서의 제어 토큰 인자.
302
- *
303
- * 키를 대신하는 것이 아니라 그 문서 하나에만 듣는다. 그래서 키가 없어도 이 값이 있으면
304
- * 도구를 부를 수 있고, 키가 있어도 남의 익명 문서에는 이 값이 있어야 한다.
305
- */
306
287
  const NOTES_ON_REPLACE_PROP = {
307
288
  type: "string",
308
289
  enum: ["keep", "abort"],
@@ -314,35 +295,9 @@ const NOTES_ON_REPLACE_PROP = {
314
295
  "anchored to its text, and the answer says how many.",
315
296
  };
316
297
 
317
- const CONTROL_TOKEN_PROP = {
318
- type: "string",
319
- description:
320
- "Control token from an anonymous publish answer (pmt_...). Lets you act on that one " +
321
- "document without an API key.",
322
- };
323
-
324
- /** 제어 토큰을 헤더로 옮긴다. 서버는 회원 인증 헤더와 나눠서 받는다. */
325
- function tokenHeader(a) {
326
- return a.controlToken ? { "X-Document-Token": String(a.controlToken) } : {};
327
- }
328
-
329
- /**
330
- * 키가 없어도 제어 토큰이 있으면 통과시킨다.
331
- *
332
- * 토큰만으로 되는 일에 키를 요구하면, 방금 익명으로 발행하고 토큰을 받은 쪽이 자기 문서를
333
- * 손대지 못한다.
334
- */
335
- function missingKeyUnlessToken(ctx, a, scopes) {
336
- return a.controlToken ? null : missingKey(ctx, scopes);
337
- }
338
-
339
298
  /** 문서 메타데이터 공통 속성. 만들기·고치기 스키마가 나눠 쓴다. */
340
299
  const DOC_META_PROPS = {
341
300
  password: { type: "string", description: "Readers must supply this password to see the content." },
342
- shareEndDate: {
343
- type: "string",
344
- description: "yyyyMMdd. The document stops being served after this date. Omit for no end date.",
345
- },
346
301
  viewerStyle: {
347
302
  type: "string",
348
303
  description:
@@ -355,18 +310,21 @@ const TOOL_DEFS = [
355
310
  name: "postmd_create_document",
356
311
  description:
357
312
  "Publish Markdown as a PostMD web page. No API key required — anyone can publish. " +
358
- "Returns docCode and data.shareUrl; hand shareUrl to people. Without a key the " +
359
- "document is anonymous: data.retainedUntil is when it is deleted and " +
360
- "data.controlToken is the only way to update or delete it, shown once and never " +
361
- "reissued — report both to the person. With an API key the document belongs to that " +
362
- "member, has no expiry, needs no token and can collect notes; groupId files it into " +
363
- "that group instead of the default one (key with documents:write).",
313
+ "Returns docCode and data.shareUrl; hand shareUrl to people. Documents have a " +
314
+ "30-day retention period (data.retainedUntil) that extends on each read. Documents " +
315
+ "without a password can be updated or deleted by anyone; password-protected documents " +
316
+ "require the password or the owner's API key. With an API key the document belongs to " +
317
+ "that member; groupId files it into that group (key with documents:write).",
318
+ annotations: { title: "Publish document", destructiveHint: false },
364
319
  inputSchema: {
365
320
  type: "object",
366
321
  properties: {
367
322
  markdown: {
368
323
  type: "string",
369
- description: "Full Markdown document as one UTF-8 string (the entire source, not a summary).",
324
+ description:
325
+ "Full Markdown document as one UTF-8 string (the entire source, not a summary). " +
326
+ "May include graph data in an HTML comment, which the viewer draws; " +
327
+ "the format is at /docs/graph.",
370
328
  },
371
329
  title: {
372
330
  type: "string",
@@ -383,8 +341,8 @@ const TOOL_DEFS = [
383
341
  name: "postmd_create_document_from_file",
384
342
  description:
385
343
  "Same as postmd_create_document, but reads the Markdown from filePath on the machine " +
386
- "running this MCP server — use it for large files instead of pasting the body. " +
387
- "Without an API key it returns data.controlToken and data.retainedUntil, same as above.",
344
+ "running this MCP server — use it for large files instead of pasting the body.",
345
+ annotations: { title: "Publish document from file", destructiveHint: false },
388
346
  inputSchema: {
389
347
  type: "object",
390
348
  properties: {
@@ -406,6 +364,7 @@ const TOOL_DEFS = [
406
364
  "Publish several .md files in one call (bulk upload). Requires an API key with " +
407
365
  "documents:write. The outer resultCode is 200 even if some files failed — check " +
408
366
  "data.succeeded and each entry in data.results.",
367
+ annotations: { title: "Publish documents from files", destructiveHint: false },
409
368
  inputSchema: {
410
369
  type: "object",
411
370
  properties: {
@@ -423,10 +382,10 @@ const TOOL_DEFS = [
423
382
  {
424
383
  name: "postmd_get_document",
425
384
  description:
426
- "Get document metadata by docCode: title, fileName, hasPassword, shareEndDate, " +
385
+ "Get document metadata by docCode: title, fileName, hasPassword, " +
427
386
  "viewerStyle, timestamps. Public — no API key needed. Content is not included; " +
428
387
  "use postmd_get_document_raw for the Markdown source.",
429
- annotations: { readOnlyHint: true },
388
+ annotations: { title: "Get document info", readOnlyHint: true },
430
389
  inputSchema: {
431
390
  type: "object",
432
391
  properties: { docCode: { type: "string", description: "Document code, e.g. P-123-456-789." } },
@@ -437,8 +396,8 @@ const TOOL_DEFS = [
437
396
  name: "postmd_get_document_raw",
438
397
  description:
439
398
  "Get the stored Markdown source of a document. Public — no API key needed. " +
440
- "Password-protected documents need `password`; expired documents cannot be read.",
441
- annotations: { readOnlyHint: true },
399
+ "Password-protected documents need `password`.",
400
+ annotations: { title: "Get document source", readOnlyHint: true },
442
401
  inputSchema: {
443
402
  type: "object",
444
403
  properties: {
@@ -451,26 +410,31 @@ const TOOL_DEFS = [
451
410
  {
452
411
  name: "postmd_update_document",
453
412
  description:
454
- "Update a document. Requires an API key with documents:write for a document you own, " +
455
- "or `controlToken` for an anonymously published one. Include `markdown` to replace " +
456
- "the stored content; any metadata field replaces that field. clearPassword / " +
457
- "clearShareEndDate remove the password / end date. Updating does not push back the " +
458
- "deletion date of an anonymous document. Replacing the content requires notesOnReplace.",
413
+ "Update a document. Password-protected documents require the password or the owner's API key; " +
414
+ "documents without a password can be updated by anyone. Include `markdown` to replace " +
415
+ "the stored content; any metadata field replaces that field. clearPassword removes the password. " +
416
+ "Replacing the content requires notesOnReplace.",
417
+ annotations: { title: "Update document", destructiveHint: true },
459
418
  inputSchema: {
460
419
  type: "object",
461
420
  properties: {
462
421
  docCode: { type: "string" },
463
422
  markdown: {
464
423
  type: "string",
465
- description: "Full new Markdown body as one UTF-8 string. Omit if only metadata changes.",
424
+ description:
425
+ "Full new Markdown body as one UTF-8 string. Omit if only metadata changes. " +
426
+ "Adding or revising a graph means sending the whole body with the graph comment in it; " +
427
+ "the format is at /docs/graph.",
466
428
  },
467
429
  title: { type: "string" },
468
430
  fileName: { type: "string", description: "Upload filename when replacing content. Default document.md." },
469
431
  ...DOC_META_PROPS,
432
+ currentPassword: {
433
+ type: "string",
434
+ description: "Current password to authenticate if the document is password-protected.",
435
+ },
470
436
  clearPassword: { type: "boolean", description: "true removes the password." },
471
- clearShareEndDate: { type: "boolean", description: "true removes the end date, making sharing open-ended." },
472
437
  notesOnReplace: NOTES_ON_REPLACE_PROP,
473
- controlToken: CONTROL_TOKEN_PROP,
474
438
  },
475
439
  required: ["docCode"],
476
440
  },
@@ -479,7 +443,8 @@ const TOOL_DEFS = [
479
443
  name: "postmd_update_document_from_file",
480
444
  description:
481
445
  "Same as postmd_update_document, but reads the new Markdown from filePath on the " +
482
- "machine running this MCP server. Takes `controlToken` the same way.",
446
+ "machine running this MCP server.",
447
+ annotations: { title: "Update document from file", destructiveHint: true },
483
448
  inputSchema: {
484
449
  type: "object",
485
450
  properties: {
@@ -491,10 +456,12 @@ const TOOL_DEFS = [
491
456
  title: { type: "string" },
492
457
  fileName: { type: "string", description: "Upload filename. Defaults to the basename of filePath." },
493
458
  ...DOC_META_PROPS,
459
+ currentPassword: {
460
+ type: "string",
461
+ description: "Current password to authenticate if the document is password-protected.",
462
+ },
494
463
  clearPassword: { type: "boolean", description: "true removes the password." },
495
- clearShareEndDate: { type: "boolean", description: "true removes the end date, making sharing open-ended." },
496
464
  notesOnReplace: NOTES_ON_REPLACE_PROP,
497
- controlToken: CONTROL_TOKEN_PROP,
498
465
  },
499
466
  required: ["docCode", "filePath", "notesOnReplace"],
500
467
  },
@@ -502,33 +469,19 @@ const TOOL_DEFS = [
502
469
  {
503
470
  name: "postmd_delete_document",
504
471
  description:
505
- "Delete a document. Requires an API key with documents:write for a document you own, " +
506
- "or `controlToken` for an anonymously published one. There is no endpoint to undo " +
507
- "this: the document stops being served at once and its stored content is erased about " +
508
- "a month later.",
509
- inputSchema: {
510
- type: "object",
511
- properties: { docCode: { type: "string" }, controlToken: CONTROL_TOKEN_PROP },
512
- required: ["docCode"],
513
- },
514
- },
515
- {
516
- name: "postmd_upload_attachment",
517
- description:
518
- "Upload an image or PDF to reference from a document. Requires an API key with " +
519
- "documents:write. Allowed types: png, jpg, jpeg, gif, webp, svg, bmp, pdf. Use the " +
520
- "returned data.url as the image/link target in your Markdown, then publish the " +
521
- "Markdown with postmd_create_document.",
472
+ "Delete a document. Password-protected documents require the password or the owner's API key; " +
473
+ "documents without a password can be deleted by anyone.",
474
+ annotations: { title: "Delete document", destructiveHint: true },
522
475
  inputSchema: {
523
476
  type: "object",
524
477
  properties: {
525
- filePath: {
478
+ docCode: { type: "string" },
479
+ password: {
526
480
  type: "string",
527
- description: "Path to the file on the MCP server host. Prefer an absolute path.",
481
+ description: "Document password if the document has one and you are not authenticated as its owner.",
528
482
  },
529
- fileName: { type: "string", description: "Upload filename. Defaults to the basename of filePath." },
530
483
  },
531
- required: ["filePath"],
484
+ required: ["docCode"],
532
485
  },
533
486
  },
534
487
  {
@@ -538,7 +491,7 @@ const TOOL_DEFS = [
538
491
  "first. Requires an API key with documents:read. Each note carries mine and " +
539
492
  "manageable flags — trust them instead of re-deriving permissions. Pass `password` " +
540
493
  "for a password-protected document.",
541
- annotations: { readOnlyHint: true },
494
+ annotations: { title: "List notes on a document", readOnlyHint: true },
542
495
  inputSchema: {
543
496
  type: "object",
544
497
  properties: {
@@ -557,6 +510,7 @@ const TOOL_DEFS = [
557
510
  "from ownership: on the key member's own document choose PRIVATE (only they see it) or " +
558
511
  "SHARED; on anyone else's document every note is SHARED, so omit scope. Documents " +
559
512
  "nobody owns — anonymous uploads and service-owned pages — take no notes at all.",
513
+ annotations: { title: "Add note or highlight", destructiveHint: false },
560
514
  inputSchema: {
561
515
  type: "object",
562
516
  properties: {
@@ -580,6 +534,7 @@ const TOOL_DEFS = [
580
534
  "Edit a note you wrote. Requires an API key with documents:write. Omitting scope " +
581
535
  "keeps the current one; a scope you do send follows the ownership rule above. The " +
582
536
  "note must keep text or a colour.",
537
+ annotations: { title: "Edit note", destructiveHint: false },
583
538
  inputSchema: {
584
539
  type: "object",
585
540
  properties: {
@@ -599,6 +554,7 @@ const TOOL_DEFS = [
599
554
  "Mark a note as settled, or undo it with resolved=false. Meaningful on SHARED " +
600
555
  "notes; the author or the document owner may set it. Requires an API key with " +
601
556
  "documents:write.",
557
+ annotations: { title: "Resolve or reopen note", destructiveHint: false },
602
558
  inputSchema: {
603
559
  type: "object",
604
560
  properties: {
@@ -614,6 +570,7 @@ const TOOL_DEFS = [
614
570
  description:
615
571
  "Delete a note: your own, or a SHARED note on a document you own. Requires an API " +
616
572
  "key with documents:write.",
573
+ annotations: { title: "Delete note", destructiveHint: true },
617
574
  inputSchema: {
618
575
  type: "object",
619
576
  properties: { docCode: { type: "string" }, noteId: { type: "number" } },
@@ -625,13 +582,13 @@ const TOOL_DEFS = [
625
582
  description:
626
583
  "List every note the key's member wrote, across all documents, with docCode and " +
627
584
  "documentTitle beside each one. Requires an API key with documents:read.",
628
- annotations: { readOnlyHint: true },
585
+ annotations: { title: "List my notes", readOnlyHint: true },
629
586
  inputSchema: { type: "object", properties: {}, required: [] },
630
587
  },
631
588
  {
632
589
  name: "postmd_list_groups",
633
590
  description: "List groups the key's member belongs to. Requires an API key with groups:read. Paged.",
634
- annotations: { readOnlyHint: true },
591
+ annotations: { title: "List groups", readOnlyHint: true },
635
592
  inputSchema: {
636
593
  type: "object",
637
594
  properties: {
@@ -646,13 +603,11 @@ const TOOL_DEFS = [
646
603
  description:
647
604
  "List documents in a group. Requires an API key with groups:read and documents:read. " +
648
605
  "Paged; q searches title and file name (substring, case-insensitive).",
649
- annotations: { readOnlyHint: true },
606
+ annotations: { title: "List documents in a group", readOnlyHint: true },
650
607
  inputSchema: {
651
608
  type: "object",
652
609
  properties: {
653
610
  groupId: { type: "number" },
654
- folderId: { type: "number", description: "Only documents filed in this folder." },
655
- rootOnly: { type: "boolean", description: "true → only documents not in any folder." },
656
611
  q: { type: "string", description: "Search text for title and file name." },
657
612
  sort: {
658
613
  type: "string",
@@ -667,15 +622,14 @@ const TOOL_DEFS = [
667
622
  {
668
623
  name: "postmd_move_document_to_group",
669
624
  description:
670
- "Move a document you own into a group you can use, optionally into a folder of that " +
671
- "group. A document belongs to exactly one group, so this replaces its current group. " +
672
- "Requires an API key with documents:write.",
625
+ "Move a document you own into a group. A document belongs to exactly one group, so this " +
626
+ "replaces its current group. Requires an API key with documents:write.",
627
+ annotations: { title: "Move document to a group", destructiveHint: false },
673
628
  inputSchema: {
674
629
  type: "object",
675
630
  properties: {
676
631
  docCode: { type: "string" },
677
632
  groupId: { type: "number" },
678
- folderId: { type: "number", description: "File it into this folder of that group." },
679
633
  },
680
634
  required: ["docCode", "groupId"],
681
635
  },
@@ -683,13 +637,12 @@ const TOOL_DEFS = [
683
637
  {
684
638
  name: "postmd_create_group",
685
639
  description:
686
- "Create a group. Requires an API key with groups:write. Documents can then be filed " +
687
- "into it and members invited from the web app.",
640
+ "Create a group. Requires an API key with groups:write. Documents can then be filed into it.",
641
+ annotations: { title: "Create group", destructiveHint: false },
688
642
  inputSchema: {
689
643
  type: "object",
690
644
  properties: {
691
645
  name: { type: "string" },
692
- expireDate: { type: "string", description: "yyyyMMdd. The group stops working after this date." },
693
646
  },
694
647
  required: ["name"],
695
648
  },
@@ -697,15 +650,13 @@ const TOOL_DEFS = [
697
650
  {
698
651
  name: "postmd_update_group",
699
652
  description:
700
- "Rename a group or change its expiry. Owner only. Requires an API key with " +
701
- "groups:write. clearExpireDate removes the expiry.",
653
+ "Rename a group. Owner only. Requires an API key with groups:write.",
654
+ annotations: { title: "Update group", destructiveHint: false },
702
655
  inputSchema: {
703
656
  type: "object",
704
657
  properties: {
705
658
  groupId: { type: "number" },
706
659
  name: { type: "string" },
707
- expireDate: { type: "string", description: "yyyyMMdd." },
708
- clearExpireDate: { type: "boolean", description: "true removes the expiry date." },
709
660
  },
710
661
  required: ["groupId"],
711
662
  },
@@ -715,6 +666,7 @@ const TOOL_DEFS = [
715
666
  description:
716
667
  "Delete a group. Owner only; the default group cannot be deleted. Documents in it " +
717
668
  "are not deleted. Requires an API key with groups:write.",
669
+ annotations: { title: "Delete group", destructiveHint: true },
718
670
  inputSchema: {
719
671
  type: "object",
720
672
  properties: { groupId: { type: "number" } },
@@ -757,7 +709,6 @@ async function runTool(ctx, name, args) {
757
709
  return textErr(e instanceof Error ? e.message : String(e));
758
710
  }
759
711
  if (a.password != null) form.append("password", String(a.password));
760
- if (a.shareEndDate != null) form.append("shareEndDate", String(a.shareEndDate));
761
712
  if (a.viewerStyle != null) form.append("viewerStyle", String(a.viewerStyle));
762
713
  if (a.groupId != null) form.append("groupId", String(a.groupId));
763
714
  const r = await apiFetch(ctx, "/documents/bulk", { method: "POST", body: form });
@@ -787,13 +738,9 @@ async function runTool(ctx, name, args) {
787
738
  }
788
739
  }
789
740
  case "postmd_update_document": {
790
- const denied = missingKeyUnlessToken(ctx, a, "documents:write");
791
- if (denied) return denied;
792
741
  return await updateDocument(ctx, a, a.markdown ?? null);
793
742
  }
794
743
  case "postmd_update_document_from_file": {
795
- const denied = missingKeyUnlessToken(ctx, a, "documents:write");
796
- if (denied) return denied;
797
744
  try {
798
745
  const { buffer, suggestedName } = await readLocalFile(a.filePath);
799
746
  return await updateDocument(ctx, { ...a, fileName: a.fileName ?? suggestedName }, buffer);
@@ -802,30 +749,12 @@ async function runTool(ctx, name, args) {
802
749
  }
803
750
  }
804
751
  case "postmd_delete_document": {
805
- const denied = missingKeyUnlessToken(ctx, a, "documents:write");
806
- if (denied) return denied;
807
752
  const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/delete`, {
808
753
  method: "POST",
809
- headers: tokenHeader(a),
754
+ headers: passwordHeader(a),
810
755
  });
811
756
  return fromEnvelope(r);
812
757
  }
813
- case "postmd_upload_attachment": {
814
- const denied = missingKey(ctx, "documents:write");
815
- if (denied) return denied;
816
- try {
817
- const { buffer, suggestedName } = await readLocalFile(a.filePath);
818
- const fileName = a.fileName || suggestedName;
819
- const ext = path.extname(fileName).slice(1).toLowerCase();
820
- const mime = ATTACHMENT_MIME[ext] || "application/octet-stream";
821
- const form = new FormData();
822
- form.append("file", new Blob([buffer], { type: mime }), fileName);
823
- const r = await apiFetch(ctx, "/documents/uploads", { method: "POST", body: form });
824
- return fromEnvelope(r);
825
- } catch (e) {
826
- return textErr(e instanceof Error ? e.message : String(e));
827
- }
828
- }
829
758
  case "postmd_list_notes": {
830
759
  const denied = missingKey(ctx, "documents:read");
831
760
  if (denied) return denied;
@@ -907,8 +836,6 @@ async function runTool(ctx, name, args) {
907
836
  const denied = missingKey(ctx, "groups:read and documents:read");
908
837
  if (denied) return denied;
909
838
  const qs = query({
910
- folderId: a.folderId,
911
- rootOnly: a.rootOnly,
912
839
  q: a.q,
913
840
  sort: a.sort,
914
841
  page: a.page,
@@ -921,7 +848,6 @@ async function runTool(ctx, name, args) {
921
848
  const denied = missingKey(ctx, "documents:write");
922
849
  if (denied) return denied;
923
850
  const body = { groupId: a.groupId };
924
- if (a.folderId != null) body.folderId = a.folderId;
925
851
  const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/group`, {
926
852
  method: "POST",
927
853
  headers: { "Content-Type": "application/json" },
@@ -933,7 +859,6 @@ async function runTool(ctx, name, args) {
933
859
  const denied = missingKey(ctx, "groups:write");
934
860
  if (denied) return denied;
935
861
  const body = { name: a.name };
936
- if (a.expireDate != null) body.expireDate = a.expireDate;
937
862
  const r = await apiFetch(ctx, "/groups", {
938
863
  method: "POST",
939
864
  headers: { "Content-Type": "application/json" },
@@ -946,8 +871,6 @@ async function runTool(ctx, name, args) {
946
871
  if (denied) return denied;
947
872
  const body = {};
948
873
  if (a.name != null) body.name = a.name;
949
- if (a.expireDate != null) body.expireDate = a.expireDate;
950
- if (a.clearExpireDate === true) body.clearExpireDate = true;
951
874
  const r = await apiFetch(ctx, `/groups/${Number(a.groupId)}/update`, {
952
875
  method: "POST",
953
876
  headers: { "Content-Type": "application/json" },