postmd-mcp-server 2.1.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/.env.example ADDED
@@ -0,0 +1,14 @@
1
+ # Everything here is optional. With no configuration at all, the server talks to
2
+ # production and can publish and read documents anonymously.
3
+
4
+ # Where PostMD runs. Defaults to https://postmd.turink.com — set this only for a
5
+ # self-hosted or local instance. Origin only, no trailing slash.
6
+ #POSTMD_BASE_URL=http://localhost:8080
7
+
8
+ # Needed for managing documents (update/delete), attachments and groups.
9
+ # A signed-in member creates keys at https://postmd.turink.com/account —
10
+ # pick the scopes your tools need (read and write are independent).
11
+ #POSTMD_API_KEY=pmk_your_key_here
12
+
13
+ # 1 / true / yes → verbose diagnostics on stderr.
14
+ #POSTMD_DEBUG=1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 reinlainer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # PostMD MCP Server
2
+
3
+ stdio [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.
4
+
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
+
7
+ **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
+
9
+ ## Requirements
10
+
11
+ - **Node.js** 20 or later
12
+ - Nothing else. An **API key** (`pmk_…`) only for the management tools.
13
+
14
+ ## Configuration
15
+
16
+ All variables are optional.
17
+
18
+ | Variable | Description |
19
+ |----------|-------------|
20
+ | `POSTMD_BASE_URL` | Defaults to `https://postmd.turink.com`. Set for a self-hosted / local instance. Origin only, no trailing slash. |
21
+ | `POSTMD_API_KEY` | `pmk_…` for the tools marked with a scope below. Sign in at [postmd.turink.com](https://postmd.turink.com), open **Account → API keys**, pick the scopes you need — read and write are independent, and a `403` usually means a missing scope. |
22
+ | `POSTMD_DEBUG` | `1` / `true` / `yes` → extra stderr logging. |
23
+
24
+ Load order: this repo's `.env` (if present) is applied via `dotenv` without overwriting variables already set by the host (e.g. MCP `env`). Do **not** commit `.env` or keys.
25
+
26
+ ## Tools
27
+
28
+ Publishing and reading — no key needed:
29
+
30
+ | Tool | Purpose |
31
+ |------|---------|
32
+ | `postmd_create_document` | Publish Markdown, get `docCode` + share URL |
33
+ | `postmd_create_document_from_file` | Same, but this server reads a local `filePath` (large files) |
34
+ | `postmd_get_document` | Metadata by `docCode` |
35
+ | `postmd_get_document_raw` | Stored Markdown body (optional `password`) |
36
+
37
+ Managing documents — key with `documents:write`:
38
+
39
+ | Tool | Purpose |
40
+ |------|---------|
41
+ | `postmd_update_document` | Replace content and/or metadata; can clear password / end date |
42
+ | `postmd_update_document_from_file` | Same, body read from a local `filePath` |
43
+ | `postmd_delete_document` | Delete (recoverable for 30 days) |
44
+ | `postmd_upload_attachment` | Upload an image/PDF, get a URL to embed in Markdown |
45
+ | `postmd_create_documents_from_files` | Bulk-publish several `.md` files in one call |
46
+ | `postmd_move_document_to_group` | Move a document into a group / folder |
47
+
48
+ Notes and highlights — key with `documents:read` / `documents:write`. A note is
49
+ 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:
52
+
53
+ | Tool | Purpose |
54
+ |------|---------|
55
+ | `postmd_list_notes` | Notes on a document: yours + every `SHARED` one |
56
+ | `postmd_add_note` | Attach a note, or a colour-only highlight to a quoted passage |
57
+ | `postmd_update_note` | Edit a note you wrote |
58
+ | `postmd_resolve_note` | Mark a `SHARED` discussion settled, or reopen it |
59
+ | `postmd_delete_note` | Delete yours, or a `SHARED` note on your document |
60
+ | `postmd_list_my_notes` | Your notes across every document |
61
+
62
+ Groups — key with `groups:read` / `groups:write`:
63
+
64
+ | Tool | Purpose |
65
+ |------|---------|
66
+ | `postmd_list_groups` | Groups visible to the key (paged) |
67
+ | `postmd_list_group_documents` | Documents in a group (paged, searchable, sortable) |
68
+ | `postmd_create_group` | New group |
69
+ | `postmd_update_group` | Rename / change expiry |
70
+ | `postmd_delete_group` | Delete a group (documents survive) |
71
+
72
+ 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.
73
+
74
+ ## Quickstart
75
+
76
+ ```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
81
+ ```
82
+
83
+ ## Client configuration
84
+
85
+ Claude Code:
86
+
87
+ ```bash
88
+ claude mcp add postmd -- node /absolute/path/to/postmd-mcp-server/src/index.js
89
+ ```
90
+
91
+ Cursor (`~/.cursor/mcp.json`) and most other stdio clients:
92
+
93
+ ```json
94
+ {
95
+ "mcpServers": {
96
+ "PostMD": {
97
+ "type": "stdio",
98
+ "command": "node",
99
+ "args": ["/absolute/path/to/postmd-mcp-server/src/index.js"],
100
+ "env": { "POSTMD_API_KEY": "pmk_…" }
101
+ }
102
+ }
103
+ }
104
+ ```
105
+
106
+ Leave `env` out entirely for publish/read-only use. `cp .env.example .env` works too — the server loads its own `.env`.
107
+
108
+ ## Smoke test
109
+
110
+ Runs the full write path against a live server and cleans up after itself. Needs a key with all four scopes.
111
+
112
+ ```bash
113
+ export POSTMD_API_KEY=pmk_…
114
+ npm run smoke
115
+ ```
116
+
117
+ Creates a group and a passworded document, reads it back, updates it, clears the password, then deletes both.
118
+
119
+ ## Stack
120
+
121
+ `@modelcontextprotocol/sdk` **1.30.0**, `dotenv`. **License:** MIT.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "postmd-mcp-server",
3
+ "version": "2.1.0",
4
+ "description": "MCP server for PostMD — publish Markdown, get a shareable web page",
5
+ "mcpName": "io.github.reinlainer/postmd-mcp-server",
6
+ "type": "module",
7
+ "main": "src/index.js",
8
+ "bin": {
9
+ "postmd-mcp-server": "src/index.js"
10
+ },
11
+ "scripts": {
12
+ "start": "node src/index.js",
13
+ "smoke": "node scripts/smoke-test.mjs"
14
+ },
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/reinlainer/postmd-mcp-server.git"
21
+ },
22
+ "homepage": "https://postmd.turink.com",
23
+ "keywords": [
24
+ "mcp",
25
+ "postmd",
26
+ "markdown",
27
+ "publishing"
28
+ ],
29
+ "license": "MIT",
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "1.30.0",
32
+ "dotenv": "16.4.5"
33
+ }
34
+ }
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 쓰기 경로를 실제 서버에 대고 한 바퀴 돈다. HTTP 로 직접 부른다 — 여기서 확인하려는
4
+ * 것은 MCP 프로토콜이 아니라 API 계약(경로·필드·봉투)이다.
5
+ *
6
+ * 네 스코프(documents:read/write, groups:read/write)를 모두 가진 키가 필요하다.
7
+ * 그룹 하나와 문서 하나를 만들었다가 끝에 모두 지운다.
8
+ */
9
+ import "../src/env.js";
10
+
11
+ const base = (process.env.POSTMD_BASE_URL || "https://postmd.turink.com").replace(/\/+$/, "");
12
+ const key = process.env.POSTMD_API_KEY;
13
+ if (!key) {
14
+ console.error("smoke: set POSTMD_API_KEY (scopes: documents:read/write, groups:read/write)");
15
+ process.exit(1);
16
+ }
17
+
18
+ const AUTH = { Authorization: `Bearer ${key}` };
19
+ let failures = 0;
20
+
21
+ function check(label, condition, detail) {
22
+ if (condition) {
23
+ console.log(`ok ${label}`);
24
+ } else {
25
+ failures++;
26
+ console.error(`FAIL ${label}${detail ? ` — ${detail}` : ""}`);
27
+ }
28
+ }
29
+
30
+ async function callJson(pathname, init = {}) {
31
+ const res = await fetch(`${base}/api/v1${pathname}`, {
32
+ ...init,
33
+ headers: { ...AUTH, ...(init.headers || {}) },
34
+ });
35
+ let json = null;
36
+ const text = await res.text();
37
+ try {
38
+ json = JSON.parse(text);
39
+ } catch {
40
+ /* 봉투가 아니면 text 로 판단한다 */
41
+ }
42
+ return { status: res.status, json, text };
43
+ }
44
+
45
+ const stamp = Date.now();
46
+ const PASSWORD = `smoke-${stamp}`;
47
+ const MARKER = `smoke marker ${stamp}`;
48
+
49
+ // 1. 그룹 만들기
50
+ const created = await callJson("/groups", {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json" },
53
+ body: JSON.stringify({ name: `mcp smoke ${stamp}` }),
54
+ });
55
+ check("create group", created.json?.resultCode === "200", created.text);
56
+ const groupId = created.json?.data?.groupId;
57
+
58
+ // 2. 비밀번호 걸린 문서를 그 그룹에 올리기
59
+ const form = new FormData();
60
+ form.append(
61
+ "file",
62
+ new Blob([`# Smoke\n\n${MARKER}\n`], { type: "text/markdown" }),
63
+ "smoke.md"
64
+ );
65
+ form.append("title", `mcp smoke ${stamp}`);
66
+ form.append("password", PASSWORD);
67
+ if (groupId != null) form.append("groupId", String(groupId));
68
+ const uploaded = await callJson("/documents", { method: "POST", body: form });
69
+ check("create document", uploaded.json?.resultCode === "200", uploaded.text);
70
+ const docCode = uploaded.json?.data?.docCode;
71
+
72
+ if (docCode) {
73
+ // 3. 메타 — 비밀번호가 걸렸다고 말해야 한다
74
+ const meta = await callJson(`/documents/${docCode}/meta`);
75
+ check("meta shows password", meta.json?.data?.hasPassword === true, meta.text);
76
+
77
+ // 4. 원문 — 비밀번호 헤더로 열리고, 올린 내용 그대로여야 한다
78
+ const raw = await callJson(`/documents/${docCode}/raw`, {
79
+ headers: { "X-Document-Password": PASSWORD },
80
+ });
81
+ check("raw readable with password", raw.status === 200 && raw.text.includes(MARKER));
82
+
83
+ // 5. 고치기 — 제목을 바꾸고 비밀번호를 푼다
84
+ const patch = new FormData();
85
+ patch.append("title", `mcp smoke ${stamp} v2`);
86
+ patch.append("clearPassword", "true");
87
+ const updated = await callJson(`/documents/${docCode}/update`, { method: "POST", body: patch });
88
+ check("update document", updated.json?.resultCode === "200", updated.text);
89
+
90
+ const meta2 = await callJson(`/documents/${docCode}/meta`);
91
+ check("password cleared", meta2.json?.data?.hasPassword === false, meta2.text);
92
+
93
+ // 6. 그룹 목록에 보이는지
94
+ if (groupId != null) {
95
+ const listed = await callJson(`/groups/${groupId}/documents?q=smoke`);
96
+ const found = (listed.json?.data || []).some((d) => d.docCode === docCode);
97
+ check("document listed in group", found, listed.text);
98
+ }
99
+
100
+ // 7. 문서 지우기
101
+ const deleted = await callJson(`/documents/${docCode}/delete`, { method: "POST" });
102
+ check("delete document", deleted.json?.resultCode === "200", deleted.text);
103
+ }
104
+
105
+ // 8. 그룹 지우기 — 예전 Agent API 에는 없어서 손으로 지워야 했던 바로 그것
106
+ if (groupId != null) {
107
+ const dropped = await callJson(`/groups/${groupId}/delete`, { method: "POST" });
108
+ check("delete group", dropped.json?.resultCode === "200", dropped.text);
109
+ }
110
+
111
+ console.log(failures ? `\n${failures} failure(s)` : "\nall good");
112
+ process.exit(failures ? 1 : 0);
package/server.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.reinlainer/postmd-mcp-server",
4
+ "description": "Publish Markdown to PostMD and get a shareable web page",
5
+ "version": "2.1.0",
6
+ "repository": {
7
+ "url": "https://github.com/reinlainer/postmd-mcp-server",
8
+ "source": "github"
9
+ },
10
+ "packages": [
11
+ {
12
+ "registryType": "npm",
13
+ "identifier": "postmd-mcp-server",
14
+ "version": "2.1.0",
15
+ "transport": {
16
+ "type": "stdio"
17
+ },
18
+ "environmentVariables": [
19
+ {
20
+ "name": "POSTMD_BASE_URL",
21
+ "description": "Where PostMD runs. Defaults to https://postmd.turink.com; set it only for a self-hosted instance.",
22
+ "isRequired": false,
23
+ "isSecret": false
24
+ },
25
+ {
26
+ "name": "POSTMD_API_KEY",
27
+ "description": "Needed only for managing documents, attachments and groups. Publishing and reading work without it.",
28
+ "isRequired": false,
29
+ "isSecret": true
30
+ },
31
+ {
32
+ "name": "POSTMD_DEBUG",
33
+ "description": "Set to 1 for verbose diagnostics on stderr.",
34
+ "isRequired": false,
35
+ "isSecret": false
36
+ }
37
+ ]
38
+ }
39
+ ]
40
+ }
package/src/env.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * 이 패키지 루트(`package.json`과 같은 디렉터리)의 `.env`를 로드한다 (실행 cwd와 무관).
3
+ * 이미 설정된 process.env 값은 덮어쓰지 않는다.
4
+ */
5
+ import { config } from "dotenv";
6
+ import { fileURLToPath } from "node:url";
7
+ import { dirname, join } from "node:path";
8
+
9
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
10
+ config({ path: join(root, ".env") });
package/src/index.js ADDED
@@ -0,0 +1,866 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PostMD 공개 API(/api/v1) — MCP stdio 서버.
4
+ *
5
+ * 필수 환경변수는 없다. POSTMD_BASE_URL 이 없으면 운영(https://postmd.turink.com)을
6
+ * 부르고, POSTMD_API_KEY 가 없으면 발행과 읽기만 할 수 있다 — 그것만으로도 PostMD 의
7
+ * 기본 쓰임은 다 된다. 문서 관리(수정·삭제)·첨부·그룹에는 키가 필요하며, 키 없이
8
+ * 그런 도구를 부르면 어느 스코프가 왜 필요한지 알려 준다.
9
+ *
10
+ * 도구 설명과 오류 문구는 영어다. 이 문장들은 사람이 아니라 에이전트가 읽는다.
11
+ */
12
+ import "./env.js";
13
+ import fs from "node:fs/promises";
14
+ import path from "node:path";
15
+ import process from "node:process";
16
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
17
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
18
+ import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
19
+
20
+ const VERSION = "2.1.0";
21
+
22
+ /** 기본은 운영이다. 대부분의 사용자는 설정 없이 바로 쓰면 된다. */
23
+ const DEFAULT_BASE_URL = "https://postmd.turink.com";
24
+
25
+ /** initialize 때 클라이언트에 전달되어, 모델이 도구를 고르기 전에 읽는다. */
26
+ const SERVER_INSTRUCTIONS =
27
+ "PostMD publishes Markdown as web pages. Use the postmd_* tools instead of calling " +
28
+ "the HTTP API directly. Creating a document needs no API key; updating, deleting, " +
29
+ "attachments and groups need POSTMD_API_KEY with the matching scope. Pass the full " +
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.";
32
+
33
+ function isDebug() {
34
+ const v = process.env.POSTMD_DEBUG;
35
+ if (!v) return false;
36
+ const s = String(v).toLowerCase();
37
+ return s === "1" || s === "true" || s === "yes";
38
+ }
39
+
40
+ function debugStderr(line) {
41
+ if (isDebug()) process.stderr.write(`[postmd-mcp-server] ${line}\n`);
42
+ }
43
+
44
+ /** 로그에는 호스트와 경로만. 쿼리에 비밀이 실릴 수 있다. */
45
+ function safeUrlForLog(urlString) {
46
+ try {
47
+ const u = new URL(urlString);
48
+ return `${u.protocol}//${u.host}${u.pathname}`;
49
+ } catch {
50
+ return "(invalid url)";
51
+ }
52
+ }
53
+
54
+ /** fetch·TLS·DNS 실패의 message·cause·code 를 한 줄로 편다. */
55
+ function formatNetworkError(err) {
56
+ const parts = [];
57
+ let e = err;
58
+ let depth = 0;
59
+ while (e != null && depth < 10) {
60
+ if (e instanceof Error) {
61
+ let line = e.message;
62
+ if (typeof e.code === "string" && e.code) line += ` [code=${e.code}]`;
63
+ parts.push(line);
64
+ e = e.cause;
65
+ } else {
66
+ parts.push(String(e));
67
+ break;
68
+ }
69
+ depth++;
70
+ }
71
+ return parts.length ? parts.join(" | ") : String(err);
72
+ }
73
+
74
+ function normalizeBaseUrl(url) {
75
+ if (!url || typeof url !== "string") return "";
76
+ return url.trim().replace(/\/+$/, "");
77
+ }
78
+
79
+ function resolveConfig() {
80
+ const base = normalizeBaseUrl(process.env.POSTMD_BASE_URL) || DEFAULT_BASE_URL;
81
+ const key = (process.env.POSTMD_API_KEY || "").trim() || null;
82
+ return { base, key };
83
+ }
84
+
85
+ function textOk(text) {
86
+ return { content: [{ type: "text", text }] };
87
+ }
88
+
89
+ function textErr(message) {
90
+ return { content: [{ type: "text", text: message }], isError: true };
91
+ }
92
+
93
+ /** 키가 필요한 도구의 문지기. 키가 없으면 네트워크에 나가지 않고 여기서 알려 준다. */
94
+ function missingKey(ctx, scopes) {
95
+ if (ctx.key) return null;
96
+ return textErr(
97
+ `This tool requires an API key with scope ${scopes}. ` +
98
+ `Set POSTMD_API_KEY — a signed-in member creates keys at ${ctx.base}/account.`
99
+ );
100
+ }
101
+
102
+ function truncate(text, max = 2000) {
103
+ const s = String(text ?? "");
104
+ return s.length > max ? `${s.slice(0, max)}… (${s.length} chars total)` : s;
105
+ }
106
+
107
+ /**
108
+ * /api/v1 호출. 키가 있으면 실어 보낸다 — 익명 발행 엔드포인트도 키를 받으면
109
+ * 그 회원 소유로 만들어 주므로, 있는 키를 숨길 이유가 없다.
110
+ */
111
+ async function apiFetch(ctx, apiPath, init = {}) {
112
+ const url = `${ctx.base}/api/v1${apiPath}`;
113
+ const headers = new Headers(init.headers);
114
+ if (ctx.key) headers.set("Authorization", `Bearer ${ctx.key}`);
115
+ try {
116
+ const res = await fetch(url, { ...init, headers });
117
+ const ct = res.headers.get("content-type") || "";
118
+ const bodyText = await res.text();
119
+ if (ct.includes("application/json")) {
120
+ try {
121
+ return { status: res.status, json: JSON.parse(bodyText), bodyText };
122
+ } catch {
123
+ return { status: res.status, json: null, bodyText };
124
+ }
125
+ }
126
+ return { status: res.status, json: null, bodyText };
127
+ } catch (e) {
128
+ const diag = formatNetworkError(e);
129
+ debugStderr(`fetch ${safeUrlForLog(url)} → ${diag}`);
130
+ return { status: 0, json: null, bodyText: "", networkError: diag };
131
+ }
132
+ }
133
+
134
+ /**
135
+ * 응답 봉투를 도구 결과로 바꾼다.
136
+ *
137
+ * API 는 실패도 JSON 봉투로 준다. HTTP 상태가 아니라 resultCode 로 갈라야 하고,
138
+ * 실패는 isError 로 표시해야 에이전트가 성공으로 오독하지 않는다 — 예전 서버는
139
+ * 오류 봉투를 성공처럼 돌려주는 문제가 있었다.
140
+ */
141
+ function fromEnvelope(r) {
142
+ if (r.networkError) return textErr(`Request failed: ${r.networkError}`);
143
+ if (!r.json) return textErr(`HTTP ${r.status}: ${truncate(r.bodyText)}`);
144
+ const text = JSON.stringify(r.json, null, 2);
145
+ return r.json.resultCode === "200" ? textOk(text) : { ...textErr(text) };
146
+ }
147
+
148
+ function query(params) {
149
+ const qs = new URLSearchParams();
150
+ for (const [k, v] of Object.entries(params)) {
151
+ if (v !== undefined && v !== null) qs.set(k, String(v));
152
+ }
153
+ const s = qs.toString();
154
+ return s ? `?${s}` : "";
155
+ }
156
+
157
+ /** 이 서버가 도는 기기의 파일을 읽는다. 원격 경로가 아니다. */
158
+ async function readLocalFile(filePath) {
159
+ const raw = String(filePath ?? "").trim();
160
+ if (!raw) throw new Error("filePath is required");
161
+ const resolved = path.resolve(raw);
162
+ const st = await fs.stat(resolved);
163
+ if (!st.isFile()) throw new Error(`Not a regular file: ${resolved}`);
164
+ return { buffer: await fs.readFile(resolved), suggestedName: path.basename(resolved) };
165
+ }
166
+
167
+ /** 첨부는 서버가 확장자로 받아 준다. Content-Type 은 예의상 맞춰 보낸다. */
168
+ const ATTACHMENT_MIME = {
169
+ png: "image/png",
170
+ jpg: "image/jpeg",
171
+ jpeg: "image/jpeg",
172
+ gif: "image/gif",
173
+ webp: "image/webp",
174
+ svg: "image/svg+xml",
175
+ bmp: "image/bmp",
176
+ pdf: "application/pdf",
177
+ };
178
+
179
+ /** 만든 문서에는 나눠 줄 주소를 붙여 준다. 에이전트의 다음 행동이 바로 그것이다. */
180
+ function addShareUrl(ctx, data) {
181
+ if (data && typeof data.docCode === "string" && data.docCode) {
182
+ data.shareUrl = `${ctx.base}/share/${encodeURIComponent(data.docCode)}`;
183
+ }
184
+ }
185
+
186
+ function documentForm(a, markdownBuffer) {
187
+ const form = new FormData();
188
+ if (markdownBuffer != null) {
189
+ const fileName = a.fileName || "document.md";
190
+ form.append("file", new Blob([markdownBuffer], { type: "text/markdown" }), fileName);
191
+ }
192
+ if (a.title != null) form.append("title", String(a.title));
193
+ if (a.password != null) form.append("password", String(a.password));
194
+ if (a.shareEndDate != null) form.append("shareEndDate", String(a.shareEndDate));
195
+ if (a.viewerStyle != null) form.append("viewerStyle", String(a.viewerStyle));
196
+ return form;
197
+ }
198
+
199
+ async function createDocument(ctx, a, markdownBuffer) {
200
+ const form = documentForm(a, markdownBuffer);
201
+ if (a.groupId != null) form.append("groupId", String(a.groupId));
202
+ const r = await apiFetch(ctx, "/documents", { method: "POST", body: form });
203
+ if (r.json?.resultCode === "200") addShareUrl(ctx, r.json.data);
204
+ return fromEnvelope(r);
205
+ }
206
+
207
+ async function updateDocument(ctx, a, markdownBuffer) {
208
+ const form = documentForm(a, markdownBuffer);
209
+ if (a.clearPassword === true) form.append("clearPassword", "true");
210
+ if (a.clearShareEndDate === true) form.append("clearShareEndDate", "true");
211
+ if ([...form.keys()].length === 0) {
212
+ return textErr("Nothing to update: pass new markdown, or at least one metadata field.");
213
+ }
214
+ const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/update`, {
215
+ method: "POST",
216
+ body: form,
217
+ });
218
+ return fromEnvelope(r);
219
+ }
220
+
221
+ /** 문서 메타데이터 공통 속성. 만들기·고치기 스키마가 나눠 쓴다. */
222
+ const DOC_META_PROPS = {
223
+ password: { type: "string", description: "Readers must supply this password to see the content." },
224
+ shareEndDate: {
225
+ type: "string",
226
+ description: "yyyyMMdd. The document stops being served after this date. Omit for no end date.",
227
+ },
228
+ viewerStyle: {
229
+ type: "string",
230
+ description:
231
+ "Viewer theme: readable (default), github, minimal, report, pamphlet or dark. Unknown values fall back to readable.",
232
+ },
233
+ };
234
+
235
+ const TOOL_DEFS = [
236
+ {
237
+ name: "postmd_create_document",
238
+ description:
239
+ "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 " +
242
+ "that group instead of the default one (key with documents:write).",
243
+ inputSchema: {
244
+ type: "object",
245
+ properties: {
246
+ markdown: {
247
+ type: "string",
248
+ description: "Full Markdown document as one UTF-8 string (the entire source, not a summary).",
249
+ },
250
+ title: {
251
+ type: "string",
252
+ description: "Shown in the viewer and link previews. Defaults to fileName without .md.",
253
+ },
254
+ fileName: { type: "string", description: "Upload filename, must end in .md. Default document.md." },
255
+ ...DOC_META_PROPS,
256
+ groupId: { type: "number", description: "File the document in this group instead of the default group (needs an API key)." },
257
+ },
258
+ required: ["markdown"],
259
+ },
260
+ },
261
+ {
262
+ name: "postmd_create_document_from_file",
263
+ description:
264
+ "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.",
266
+ inputSchema: {
267
+ type: "object",
268
+ properties: {
269
+ filePath: {
270
+ type: "string",
271
+ description: "Path to a .md file on the MCP server host, read as UTF-8. Prefer an absolute path.",
272
+ },
273
+ title: { type: "string", description: "Defaults to the file name without .md." },
274
+ fileName: { type: "string", description: "Upload filename. Defaults to the basename of filePath." },
275
+ ...DOC_META_PROPS,
276
+ groupId: { type: "number", description: "File the document in this group instead of the default group (needs an API key)." },
277
+ },
278
+ required: ["filePath"],
279
+ },
280
+ },
281
+ {
282
+ name: "postmd_create_documents_from_files",
283
+ description:
284
+ "Publish several .md files in one call (bulk upload). Requires an API key with " +
285
+ "documents:write. The outer resultCode is 200 even if some files failed — check " +
286
+ "data.succeeded and each entry in data.results.",
287
+ inputSchema: {
288
+ type: "object",
289
+ properties: {
290
+ filePaths: {
291
+ type: "array",
292
+ items: { type: "string" },
293
+ description: "Paths to .md files on the MCP server host. Each becomes its own document.",
294
+ },
295
+ ...DOC_META_PROPS,
296
+ groupId: { type: "number", description: "File every document in this group instead of the default group." },
297
+ },
298
+ required: ["filePaths"],
299
+ },
300
+ },
301
+ {
302
+ name: "postmd_get_document",
303
+ description:
304
+ "Get document metadata by docCode: title, fileName, hasPassword, shareEndDate, " +
305
+ "viewerStyle, timestamps. Public — no API key needed. Content is not included; " +
306
+ "use postmd_get_document_raw for the Markdown source.",
307
+ annotations: { readOnlyHint: true },
308
+ inputSchema: {
309
+ type: "object",
310
+ properties: { docCode: { type: "string", description: "Document code, e.g. P-123-456-789." } },
311
+ required: ["docCode"],
312
+ },
313
+ },
314
+ {
315
+ name: "postmd_get_document_raw",
316
+ description:
317
+ "Get the stored Markdown source of a document. Public — no API key needed. " +
318
+ "Password-protected documents need `password`; expired documents cannot be read.",
319
+ annotations: { readOnlyHint: true },
320
+ inputSchema: {
321
+ type: "object",
322
+ properties: {
323
+ docCode: { type: "string" },
324
+ password: { type: "string", description: "Plain document password, if the document has one." },
325
+ },
326
+ required: ["docCode"],
327
+ },
328
+ },
329
+ {
330
+ name: "postmd_update_document",
331
+ 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.",
335
+ inputSchema: {
336
+ type: "object",
337
+ properties: {
338
+ docCode: { type: "string" },
339
+ markdown: {
340
+ type: "string",
341
+ description: "Full new Markdown body as one UTF-8 string. Omit if only metadata changes.",
342
+ },
343
+ title: { type: "string" },
344
+ fileName: { type: "string", description: "Upload filename when replacing content. Default document.md." },
345
+ ...DOC_META_PROPS,
346
+ clearPassword: { type: "boolean", description: "true removes the password." },
347
+ clearShareEndDate: { type: "boolean", description: "true removes the end date, making sharing open-ended." },
348
+ },
349
+ required: ["docCode"],
350
+ },
351
+ },
352
+ {
353
+ name: "postmd_update_document_from_file",
354
+ description:
355
+ "Same as postmd_update_document, but reads the new Markdown from filePath on the " +
356
+ "machine running this MCP server.",
357
+ inputSchema: {
358
+ type: "object",
359
+ properties: {
360
+ docCode: { type: "string" },
361
+ filePath: {
362
+ type: "string",
363
+ description: "Path to a .md file on the MCP server host, read as UTF-8. Prefer an absolute path.",
364
+ },
365
+ title: { type: "string" },
366
+ fileName: { type: "string", description: "Upload filename. Defaults to the basename of filePath." },
367
+ ...DOC_META_PROPS,
368
+ clearPassword: { type: "boolean", description: "true removes the password." },
369
+ clearShareEndDate: { type: "boolean", description: "true removes the end date, making sharing open-ended." },
370
+ },
371
+ required: ["docCode", "filePath"],
372
+ },
373
+ },
374
+ {
375
+ name: "postmd_delete_document",
376
+ description:
377
+ "Delete a document you own (recoverable for 30 days, then purged). Requires an API " +
378
+ "key with documents:write.",
379
+ inputSchema: {
380
+ type: "object",
381
+ properties: { docCode: { type: "string" } },
382
+ required: ["docCode"],
383
+ },
384
+ },
385
+ {
386
+ name: "postmd_upload_attachment",
387
+ description:
388
+ "Upload an image or PDF to reference from a document. Requires an API key with " +
389
+ "documents:write. Allowed types: png, jpg, jpeg, gif, webp, svg, bmp, pdf. Use the " +
390
+ "returned data.url as the image/link target in your Markdown, then publish the " +
391
+ "Markdown with postmd_create_document.",
392
+ inputSchema: {
393
+ type: "object",
394
+ properties: {
395
+ filePath: {
396
+ type: "string",
397
+ description: "Path to the file on the MCP server host. Prefer an absolute path.",
398
+ },
399
+ fileName: { type: "string", description: "Upload filename. Defaults to the basename of filePath." },
400
+ },
401
+ required: ["filePath"],
402
+ },
403
+ },
404
+ {
405
+ name: "postmd_list_notes",
406
+ description:
407
+ "List notes and highlights on a document: your own plus every SHARED one, newest " +
408
+ "first. Requires an API key with documents:read. Each note carries mine and " +
409
+ "manageable flags — trust them instead of re-deriving permissions. Pass `password` " +
410
+ "for a password-protected document.",
411
+ annotations: { readOnlyHint: true },
412
+ inputSchema: {
413
+ type: "object",
414
+ properties: {
415
+ docCode: { type: "string" },
416
+ password: { type: "string", description: "Plain document password, if the document has one." },
417
+ },
418
+ required: ["docCode"],
419
+ },
420
+ },
421
+ {
422
+ name: "postmd_add_note",
423
+ description:
424
+ "Attach a note or highlight to a document. Requires an API key with documents:write. " +
425
+ "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.",
429
+ inputSchema: {
430
+ type: "object",
431
+ properties: {
432
+ docCode: { type: "string" },
433
+ content: { type: "string", description: "Note text, up to 4000 characters. Omit for a colour-only highlight." },
434
+ quotedContent: {
435
+ type: "string",
436
+ description: "Passage of the body this note points to, up to 4000 characters. Matched by text, so it survives edits elsewhere.",
437
+ },
438
+ scope: { type: "string", description: "PRIVATE (default) or SHARED." },
439
+ color: { type: "string", description: "YELLOW, GREEN, BLUE or PURPLE." },
440
+ textStart: { type: "number", description: "Character offset where the quote starts in the body. Optional; speeds up re-anchoring." },
441
+ password: { type: "string", description: "Plain document password, if the document has one." },
442
+ },
443
+ required: ["docCode"],
444
+ },
445
+ },
446
+ {
447
+ name: "postmd_update_note",
448
+ description:
449
+ "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.",
451
+ inputSchema: {
452
+ type: "object",
453
+ properties: {
454
+ docCode: { type: "string" },
455
+ noteId: { type: "number" },
456
+ content: { type: "string" },
457
+ quotedContent: { type: "string" },
458
+ scope: { type: "string", description: "PRIVATE or SHARED. Omit to keep the current scope." },
459
+ color: { type: "string", description: "YELLOW, GREEN, BLUE or PURPLE." },
460
+ },
461
+ required: ["docCode", "noteId"],
462
+ },
463
+ },
464
+ {
465
+ name: "postmd_resolve_note",
466
+ description:
467
+ "Mark a note as settled, or undo it with resolved=false. Meaningful on SHARED " +
468
+ "notes; the author or the document owner may set it. Requires an API key with " +
469
+ "documents:write.",
470
+ inputSchema: {
471
+ type: "object",
472
+ properties: {
473
+ docCode: { type: "string" },
474
+ noteId: { type: "number" },
475
+ resolved: { type: "boolean", description: "true marks it settled; false reopens it." },
476
+ },
477
+ required: ["docCode", "noteId", "resolved"],
478
+ },
479
+ },
480
+ {
481
+ name: "postmd_delete_note",
482
+ description:
483
+ "Delete a note: your own, or a SHARED note on a document you own. Requires an API " +
484
+ "key with documents:write.",
485
+ inputSchema: {
486
+ type: "object",
487
+ properties: { docCode: { type: "string" }, noteId: { type: "number" } },
488
+ required: ["docCode", "noteId"],
489
+ },
490
+ },
491
+ {
492
+ name: "postmd_list_my_notes",
493
+ description:
494
+ "List every note the key's member wrote, across all documents, with docCode and " +
495
+ "documentTitle beside each one. Requires an API key with documents:read.",
496
+ annotations: { readOnlyHint: true },
497
+ inputSchema: { type: "object", properties: {}, required: [] },
498
+ },
499
+ {
500
+ name: "postmd_list_groups",
501
+ description: "List groups the key's member belongs to. Requires an API key with groups:read. Paged.",
502
+ annotations: { readOnlyHint: true },
503
+ inputSchema: {
504
+ type: "object",
505
+ properties: {
506
+ page: { type: "number", description: "1-based page number." },
507
+ size: { type: "number", description: "Items per page." },
508
+ },
509
+ required: [],
510
+ },
511
+ },
512
+ {
513
+ name: "postmd_list_group_documents",
514
+ description:
515
+ "List documents in a group. Requires an API key with groups:read and documents:read. " +
516
+ "Paged; q searches title and file name (substring, case-insensitive).",
517
+ annotations: { readOnlyHint: true },
518
+ inputSchema: {
519
+ type: "object",
520
+ properties: {
521
+ groupId: { type: "number" },
522
+ folderId: { type: "number", description: "Only documents filed in this folder." },
523
+ rootOnly: { type: "boolean", description: "true → only documents not in any folder." },
524
+ q: { type: "string", description: "Search text for title and file name." },
525
+ sort: {
526
+ type: "string",
527
+ description: "recent (default), oldest, name, name_desc, created or created_asc.",
528
+ },
529
+ page: { type: "number" },
530
+ size: { type: "number" },
531
+ },
532
+ required: ["groupId"],
533
+ },
534
+ },
535
+ {
536
+ name: "postmd_move_document_to_group",
537
+ description:
538
+ "Move a document you own into a group you can use, optionally into a folder of that " +
539
+ "group. A document belongs to exactly one group, so this replaces its current group. " +
540
+ "Requires an API key with documents:write.",
541
+ inputSchema: {
542
+ type: "object",
543
+ properties: {
544
+ docCode: { type: "string" },
545
+ groupId: { type: "number" },
546
+ folderId: { type: "number", description: "File it into this folder of that group." },
547
+ },
548
+ required: ["docCode", "groupId"],
549
+ },
550
+ },
551
+ {
552
+ name: "postmd_create_group",
553
+ description:
554
+ "Create a group. Requires an API key with groups:write. Documents can then be filed " +
555
+ "into it and members invited from the web app.",
556
+ inputSchema: {
557
+ type: "object",
558
+ properties: {
559
+ name: { type: "string" },
560
+ expireDate: { type: "string", description: "yyyyMMdd. The group stops working after this date." },
561
+ },
562
+ required: ["name"],
563
+ },
564
+ },
565
+ {
566
+ name: "postmd_update_group",
567
+ description:
568
+ "Rename a group or change its expiry. Owner only. Requires an API key with " +
569
+ "groups:write. clearExpireDate removes the expiry.",
570
+ inputSchema: {
571
+ type: "object",
572
+ properties: {
573
+ groupId: { type: "number" },
574
+ name: { type: "string" },
575
+ expireDate: { type: "string", description: "yyyyMMdd." },
576
+ clearExpireDate: { type: "boolean", description: "true removes the expiry date." },
577
+ },
578
+ required: ["groupId"],
579
+ },
580
+ },
581
+ {
582
+ name: "postmd_delete_group",
583
+ description:
584
+ "Delete a group. Owner only; the default group cannot be deleted. Documents in it " +
585
+ "are not deleted. Requires an API key with groups:write.",
586
+ inputSchema: {
587
+ type: "object",
588
+ properties: { groupId: { type: "number" } },
589
+ required: ["groupId"],
590
+ },
591
+ },
592
+ ];
593
+
594
+ async function runTool(ctx, name, args) {
595
+ const a = args && typeof args === "object" ? args : {};
596
+
597
+ switch (name) {
598
+ case "postmd_create_document": {
599
+ if (typeof a.markdown !== "string" || a.markdown.length === 0) {
600
+ return textErr("markdown is required: the full document source as one string.");
601
+ }
602
+ return await createDocument(ctx, a, a.markdown);
603
+ }
604
+ case "postmd_create_document_from_file": {
605
+ try {
606
+ const { buffer, suggestedName } = await readLocalFile(a.filePath);
607
+ return await createDocument(ctx, { ...a, fileName: a.fileName ?? suggestedName }, buffer);
608
+ } catch (e) {
609
+ return textErr(e instanceof Error ? e.message : String(e));
610
+ }
611
+ }
612
+ case "postmd_create_documents_from_files": {
613
+ const denied = missingKey(ctx, "documents:write");
614
+ if (denied) return denied;
615
+ if (!Array.isArray(a.filePaths) || a.filePaths.length === 0) {
616
+ return textErr("filePaths is required: one path per document.");
617
+ }
618
+ const form = new FormData();
619
+ try {
620
+ for (const p of a.filePaths) {
621
+ const { buffer, suggestedName } = await readLocalFile(p);
622
+ form.append("files", new Blob([buffer], { type: "text/markdown" }), suggestedName);
623
+ }
624
+ } catch (e) {
625
+ return textErr(e instanceof Error ? e.message : String(e));
626
+ }
627
+ if (a.password != null) form.append("password", String(a.password));
628
+ if (a.shareEndDate != null) form.append("shareEndDate", String(a.shareEndDate));
629
+ if (a.viewerStyle != null) form.append("viewerStyle", String(a.viewerStyle));
630
+ if (a.groupId != null) form.append("groupId", String(a.groupId));
631
+ const r = await apiFetch(ctx, "/documents/bulk", { method: "POST", body: form });
632
+ if (r.json?.resultCode === "200" && Array.isArray(r.json.data?.results)) {
633
+ for (const item of r.json.data.results) addShareUrl(ctx, item);
634
+ }
635
+ return fromEnvelope(r);
636
+ }
637
+ case "postmd_get_document": {
638
+ const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/meta`);
639
+ return fromEnvelope(r);
640
+ }
641
+ case "postmd_get_document_raw": {
642
+ const url = `${ctx.base}/api/v1/documents/${encodeURIComponent(a.docCode)}/raw`;
643
+ const headers = {};
644
+ if (ctx.key) headers.Authorization = `Bearer ${ctx.key}`;
645
+ if (a.password) headers["X-Document-Password"] = String(a.password);
646
+ try {
647
+ const res = await fetch(url, { headers });
648
+ const t = await res.text();
649
+ if (!res.ok) return textErr(`HTTP ${res.status}: ${truncate(t)}`);
650
+ return textOk(t);
651
+ } catch (e) {
652
+ const diag = formatNetworkError(e);
653
+ debugStderr(`fetch ${safeUrlForLog(url)} → ${diag}`);
654
+ return textErr(`Request failed: ${diag}`);
655
+ }
656
+ }
657
+ case "postmd_update_document": {
658
+ const denied = missingKey(ctx, "documents:write");
659
+ if (denied) return denied;
660
+ return await updateDocument(ctx, a, a.markdown ?? null);
661
+ }
662
+ case "postmd_update_document_from_file": {
663
+ const denied = missingKey(ctx, "documents:write");
664
+ if (denied) return denied;
665
+ try {
666
+ const { buffer, suggestedName } = await readLocalFile(a.filePath);
667
+ return await updateDocument(ctx, { ...a, fileName: a.fileName ?? suggestedName }, buffer);
668
+ } catch (e) {
669
+ return textErr(e instanceof Error ? e.message : String(e));
670
+ }
671
+ }
672
+ case "postmd_delete_document": {
673
+ const denied = missingKey(ctx, "documents:write");
674
+ if (denied) return denied;
675
+ const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/delete`, {
676
+ method: "POST",
677
+ });
678
+ return fromEnvelope(r);
679
+ }
680
+ case "postmd_upload_attachment": {
681
+ const denied = missingKey(ctx, "documents:write");
682
+ if (denied) return denied;
683
+ try {
684
+ const { buffer, suggestedName } = await readLocalFile(a.filePath);
685
+ const fileName = a.fileName || suggestedName;
686
+ const ext = path.extname(fileName).slice(1).toLowerCase();
687
+ const mime = ATTACHMENT_MIME[ext] || "application/octet-stream";
688
+ const form = new FormData();
689
+ form.append("file", new Blob([buffer], { type: mime }), fileName);
690
+ const r = await apiFetch(ctx, "/documents/uploads", { method: "POST", body: form });
691
+ return fromEnvelope(r);
692
+ } catch (e) {
693
+ return textErr(e instanceof Error ? e.message : String(e));
694
+ }
695
+ }
696
+ case "postmd_list_notes": {
697
+ const denied = missingKey(ctx, "documents:read");
698
+ if (denied) return denied;
699
+ const headers = {};
700
+ if (a.password) headers["X-Document-Password"] = String(a.password);
701
+ const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/notes`, { headers });
702
+ return fromEnvelope(r);
703
+ }
704
+ case "postmd_add_note": {
705
+ const denied = missingKey(ctx, "documents:write");
706
+ if (denied) return denied;
707
+ const body = {};
708
+ if (a.content != null) body.content = String(a.content);
709
+ if (a.quotedContent != null) body.quotedContent = String(a.quotedContent);
710
+ if (a.scope != null) body.scope = String(a.scope);
711
+ if (a.color != null) body.color = String(a.color);
712
+ if (a.textStart != null) body.textStart = Number(a.textStart);
713
+ const headers = { "Content-Type": "application/json" };
714
+ if (a.password) headers["X-Document-Password"] = String(a.password);
715
+ const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/notes`, {
716
+ method: "POST",
717
+ headers,
718
+ body: JSON.stringify(body),
719
+ });
720
+ return fromEnvelope(r);
721
+ }
722
+ case "postmd_update_note": {
723
+ const denied = missingKey(ctx, "documents:write");
724
+ if (denied) return denied;
725
+ const body = {};
726
+ if (a.content != null) body.content = String(a.content);
727
+ if (a.quotedContent != null) body.quotedContent = String(a.quotedContent);
728
+ if (a.scope != null) body.scope = String(a.scope);
729
+ if (a.color != null) body.color = String(a.color);
730
+ const r = await apiFetch(
731
+ ctx,
732
+ `/documents/${encodeURIComponent(a.docCode)}/notes/${Number(a.noteId)}/update`,
733
+ { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
734
+ );
735
+ return fromEnvelope(r);
736
+ }
737
+ case "postmd_resolve_note": {
738
+ const denied = missingKey(ctx, "documents:write");
739
+ if (denied) return denied;
740
+ const r = await apiFetch(
741
+ ctx,
742
+ `/documents/${encodeURIComponent(a.docCode)}/notes/${Number(a.noteId)}/resolve`,
743
+ {
744
+ method: "POST",
745
+ headers: { "Content-Type": "application/json" },
746
+ body: JSON.stringify({ resolved: a.resolved === true }),
747
+ }
748
+ );
749
+ return fromEnvelope(r);
750
+ }
751
+ case "postmd_delete_note": {
752
+ const denied = missingKey(ctx, "documents:write");
753
+ if (denied) return denied;
754
+ const r = await apiFetch(
755
+ ctx,
756
+ `/documents/${encodeURIComponent(a.docCode)}/notes/${Number(a.noteId)}/delete`,
757
+ { method: "POST" }
758
+ );
759
+ return fromEnvelope(r);
760
+ }
761
+ case "postmd_list_my_notes": {
762
+ const denied = missingKey(ctx, "documents:read");
763
+ if (denied) return denied;
764
+ const r = await apiFetch(ctx, "/notes");
765
+ return fromEnvelope(r);
766
+ }
767
+ case "postmd_list_groups": {
768
+ const denied = missingKey(ctx, "groups:read");
769
+ if (denied) return denied;
770
+ const r = await apiFetch(ctx, `/groups${query({ page: a.page, size: a.size })}`);
771
+ return fromEnvelope(r);
772
+ }
773
+ case "postmd_list_group_documents": {
774
+ const denied = missingKey(ctx, "groups:read and documents:read");
775
+ if (denied) return denied;
776
+ const qs = query({
777
+ folderId: a.folderId,
778
+ rootOnly: a.rootOnly,
779
+ q: a.q,
780
+ sort: a.sort,
781
+ page: a.page,
782
+ size: a.size,
783
+ });
784
+ const r = await apiFetch(ctx, `/groups/${Number(a.groupId)}/documents${qs}`);
785
+ return fromEnvelope(r);
786
+ }
787
+ case "postmd_move_document_to_group": {
788
+ const denied = missingKey(ctx, "documents:write");
789
+ if (denied) return denied;
790
+ const body = { groupId: a.groupId };
791
+ if (a.folderId != null) body.folderId = a.folderId;
792
+ const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/group`, {
793
+ method: "POST",
794
+ headers: { "Content-Type": "application/json" },
795
+ body: JSON.stringify(body),
796
+ });
797
+ return fromEnvelope(r);
798
+ }
799
+ case "postmd_create_group": {
800
+ const denied = missingKey(ctx, "groups:write");
801
+ if (denied) return denied;
802
+ const body = { name: a.name };
803
+ if (a.expireDate != null) body.expireDate = a.expireDate;
804
+ const r = await apiFetch(ctx, "/groups", {
805
+ method: "POST",
806
+ headers: { "Content-Type": "application/json" },
807
+ body: JSON.stringify(body),
808
+ });
809
+ return fromEnvelope(r);
810
+ }
811
+ case "postmd_update_group": {
812
+ const denied = missingKey(ctx, "groups:write");
813
+ if (denied) return denied;
814
+ const body = {};
815
+ if (a.name != null) body.name = a.name;
816
+ if (a.expireDate != null) body.expireDate = a.expireDate;
817
+ if (a.clearExpireDate === true) body.clearExpireDate = true;
818
+ const r = await apiFetch(ctx, `/groups/${Number(a.groupId)}/update`, {
819
+ method: "POST",
820
+ headers: { "Content-Type": "application/json" },
821
+ body: JSON.stringify(body),
822
+ });
823
+ return fromEnvelope(r);
824
+ }
825
+ case "postmd_delete_group": {
826
+ const denied = missingKey(ctx, "groups:write");
827
+ if (denied) return denied;
828
+ const r = await apiFetch(ctx, `/groups/${Number(a.groupId)}/delete`, { method: "POST" });
829
+ return fromEnvelope(r);
830
+ }
831
+ default:
832
+ return textErr(`Unknown tool: ${name}`);
833
+ }
834
+ }
835
+
836
+ async function main() {
837
+ const ctx = resolveConfig();
838
+ debugStderr(
839
+ `debug on | base ${ctx.base} | key ${ctx.key ? "set" : "not set (publish/read only)"}`
840
+ );
841
+
842
+ const server = new Server(
843
+ { name: "postmd-mcp-server", version: VERSION },
844
+ { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS }
845
+ );
846
+
847
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }));
848
+
849
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
850
+ const { name, arguments: args } = request.params;
851
+ try {
852
+ return await runTool(ctx, name, args);
853
+ } catch (e) {
854
+ const msg = formatNetworkError(e);
855
+ debugStderr(`tool ${name} threw: ${msg}`);
856
+ return textErr(msg);
857
+ }
858
+ });
859
+
860
+ await server.connect(new StdioServerTransport());
861
+ }
862
+
863
+ main().catch((e) => {
864
+ process.stderr.write(String(e instanceof Error ? e.stack : e) + "\n");
865
+ process.exit(1);
866
+ });