postmd-mcp-server 2.3.0 → 2.5.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/src/server.js ADDED
@@ -0,0 +1,944 @@
1
+ /**
2
+ * PostMD 공개 API(/api/v1) — MCP 서버의 본체.
3
+ *
4
+ * 도구 정의와 실행이 여기 있고 전송은 없다. 진입점이 둘이라 갈라 두었다 —
5
+ * `index.js` 가 stdio, `http.js` 가 원격이다. 도구를 두 벌로 갖지 않기 위한 분리이며,
6
+ * 전송에 따라 달라지는 것은 어느 도구를 여느냐 하나뿐이다(REMOTE_TOOLS).
7
+ *
8
+ * 필수 환경변수는 없다. POSTMD_BASE_URL 이 없으면 운영(https://postmd.turink.com)을
9
+ * 부르고, POSTMD_API_KEY 가 없으면 발행과 읽기만 할 수 있다 — 그것만으로도 PostMD 의
10
+ * 기본 쓰임은 다 된다. 문서 관리(수정·삭제)·첨부·그룹에는 키가 필요하며, 키 없이
11
+ * 그런 도구를 부르면 어느 스코프가 왜 필요한지 알려 준다.
12
+ *
13
+ * 도구 설명과 오류 문구는 영어다. 이 문장들은 사람이 아니라 에이전트가 읽는다.
14
+ */
15
+ import "./env.js";
16
+ import fs from "node:fs/promises";
17
+ import path from "node:path";
18
+ import process from "node:process";
19
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
20
+ import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
21
+ import { createRequire } from "node:module";
22
+
23
+ /**
24
+ * 손으로 적어 두면 어긋난다. 실제로 package.json 이 2.3.0 일 때 여기가 2.2.0 이어서
25
+ * 서버가 클라이언트에 옛 판을 알리고 있었다. 발행 워크플로도 이 값은 검사하지 않는다.
26
+ */
27
+ const VERSION = createRequire(import.meta.url)("../package.json").version;
28
+
29
+ /** 기본은 운영이다. 대부분의 사용자는 설정 없이 바로 쓰면 된다. */
30
+ const DEFAULT_BASE_URL = "https://postmd.turink.com";
31
+
32
+ /** initialize 때 클라이언트에 전달되어, 모델이 도구를 고르기 전에 읽는다. */
33
+ const INSTRUCTIONS_LOCAL =
34
+ "PostMD publishes Markdown as web pages. Use the postmd_* tools instead of calling " +
35
+ "the HTTP API directly. Documents are kept for 30 days from publication or their last read. " +
36
+ "Documents without a password can be updated or deleted by anyone; password-protected " +
37
+ "documents require the password or the owner's API key. Pass the full Markdown in " +
38
+ "`markdown`, or pass a local `filePath` so this server reads the file itself. A successful " +
39
+ "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.";
40
+
41
+ /**
42
+ * 원격에는 키를 건네줄 길이 없고 서버 기계에 사용자의 파일도 없다. 그래서 그 둘을 말하지
43
+ * 않는다 - 쓸 수 없는 것을 알려 주면 에이전트가 그것을 시도하다 실패한다.
44
+ */
45
+ const INSTRUCTIONS_REMOTE =
46
+ "PostMD publishes Markdown as web pages. Use the postmd_* tools instead of calling " +
47
+ "the HTTP API directly. Nothing here needs an account, a sign-in or an API key. " +
48
+ "Documents are kept for 30 days from publication or their last read. Documents without a " +
49
+ "password can be updated or deleted by anyone. If a document has a password, provide it to " +
50
+ "update or delete. Pass the full Markdown in `markdown`. A successful create returns " +
51
+ "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.";
52
+
53
+ /**
54
+ * 원격에서 여는 도구. 자격 증명 없이 끝까지 가는 것만 남겼다 - 발행과 조회, 그리고 발행할
55
+ * 때 받은 제어 토큰으로 하는 수정·삭제다.
56
+ *
57
+ * 뺀 것은 두 부류다. API 키를 요구하는 도구는 원격에 키를 줄 길이 없어 부르면 반드시
58
+ * 실패하고, `filePath` 를 받는 도구는 그 파일이 이 서버가 도는 기계에 없다.
59
+ *
60
+ * 목록에 적은 것만 열린다. 새 도구가 생겨도 여기 이름을 적기 전에는 원격으로 나가지
61
+ * 않는다. 반대로 두면 키가 필요한 도구가 의도치 않게 외부에 노출된다.
62
+ */
63
+ const REMOTE_TOOLS = new Set([
64
+ "postmd_create_document",
65
+ "postmd_get_document",
66
+ "postmd_get_document_raw",
67
+ "postmd_update_document",
68
+ "postmd_delete_document",
69
+ ]);
70
+
71
+ function isDebug() {
72
+ const v = process.env.POSTMD_DEBUG;
73
+ if (!v) return false;
74
+ const s = String(v).toLowerCase();
75
+ return s === "1" || s === "true" || s === "yes";
76
+ }
77
+
78
+ function debugStderr(line) {
79
+ if (isDebug()) process.stderr.write(`[postmd-mcp-server] ${line}\n`);
80
+ }
81
+
82
+ /** 로그에는 호스트와 경로만. 쿼리에 비밀이 실릴 수 있다. */
83
+ function safeUrlForLog(urlString) {
84
+ try {
85
+ const u = new URL(urlString);
86
+ return `${u.protocol}//${u.host}${u.pathname}`;
87
+ } catch {
88
+ return "(invalid url)";
89
+ }
90
+ }
91
+
92
+ /** fetch·TLS·DNS 실패의 message·cause·code 를 한 줄로 편다. */
93
+ function formatNetworkError(err) {
94
+ const parts = [];
95
+ let e = err;
96
+ let depth = 0;
97
+ while (e != null && depth < 10) {
98
+ if (e instanceof Error) {
99
+ let line = e.message;
100
+ if (typeof e.code === "string" && e.code) line += ` [code=${e.code}]`;
101
+ parts.push(line);
102
+ e = e.cause;
103
+ } else {
104
+ parts.push(String(e));
105
+ break;
106
+ }
107
+ depth++;
108
+ }
109
+ return parts.length ? parts.join(" | ") : String(err);
110
+ }
111
+
112
+ function normalizeBaseUrl(url) {
113
+ if (!url || typeof url !== "string") return "";
114
+ return url.trim().replace(/\/+$/, "");
115
+ }
116
+
117
+ function resolveConfig() {
118
+ const base = normalizeBaseUrl(process.env.POSTMD_BASE_URL) || DEFAULT_BASE_URL;
119
+ const key = (process.env.POSTMD_API_KEY || "").trim() || null;
120
+ return { base, key };
121
+ }
122
+
123
+ function textOk(text) {
124
+ return { content: [{ type: "text", text }] };
125
+ }
126
+
127
+ function textErr(message) {
128
+ return { content: [{ type: "text", text: message }], isError: true };
129
+ }
130
+
131
+ /**
132
+ * 키가 필요한 도구의 문지기. 키가 없으면 네트워크에 나가지 않고 여기서 알려 준다.
133
+ *
134
+ * 원격에는 키를 건네줄 길 자체가 없다. 그 자리에서 환경변수를 설정하라고 하면 부르는 쪽이
135
+ * 할 수 없는 일을 시키는 것이므로, 대신 발행할 때 받은 제어 토큰을 가리킨다.
136
+ */
137
+ function missingKey(ctx, scopes) {
138
+ if (ctx.key) return null;
139
+ if (ctx.remote) {
140
+ return textErr(
141
+ "This tool requires an API key, which cannot be passed to the remote server. " +
142
+ "Run the local server: npx -y postmd-mcp-server, with POSTMD_API_KEY set."
143
+ );
144
+ }
145
+ return textErr(
146
+ `This tool requires an API key with scope ${scopes}. ` +
147
+ `Set POSTMD_API_KEY — a signed-in member creates keys at ${ctx.base}/account.`
148
+ );
149
+ }
150
+
151
+ function truncate(text, max = 2000) {
152
+ const s = String(text ?? "");
153
+ return s.length > max ? `${s.slice(0, max)}… (${s.length} chars total)` : s;
154
+ }
155
+
156
+ /**
157
+ * /api/v1 호출. 키가 있으면 실어 보낸다 — 익명 발행 엔드포인트도 키를 받으면
158
+ * 그 회원 소유로 만들어 주므로, 있는 키를 숨길 이유가 없다.
159
+ */
160
+ async function apiFetch(ctx, apiPath, init = {}) {
161
+ const url = `${ctx.base}/api/v1${apiPath}`;
162
+ const headers = new Headers(init.headers);
163
+ if (ctx.key) headers.set("Authorization", `Bearer ${ctx.key}`);
164
+ try {
165
+ const res = await fetch(url, { ...init, headers });
166
+ const ct = res.headers.get("content-type") || "";
167
+ const bodyText = await res.text();
168
+ if (ct.includes("application/json")) {
169
+ try {
170
+ return { status: res.status, json: JSON.parse(bodyText), bodyText };
171
+ } catch {
172
+ return { status: res.status, json: null, bodyText };
173
+ }
174
+ }
175
+ return { status: res.status, json: null, bodyText };
176
+ } catch (e) {
177
+ const diag = formatNetworkError(e);
178
+ debugStderr(`fetch ${safeUrlForLog(url)} → ${diag}`);
179
+ return { status: 0, json: null, bodyText: "", networkError: diag };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * 응답 봉투를 도구 결과로 바꾼다.
185
+ *
186
+ * API 는 실패도 JSON 봉투로 준다. HTTP 상태가 아니라 resultCode 로 갈라야 하고,
187
+ * 실패는 isError 로 표시해야 에이전트가 성공으로 오독하지 않는다 — 예전 서버는
188
+ * 오류 봉투를 성공처럼 돌려주는 문제가 있었다.
189
+ */
190
+ function fromEnvelope(r) {
191
+ if (r.networkError) return textErr(`Request failed: ${r.networkError}`);
192
+ if (!r.json) return textErr(`HTTP ${r.status}: ${truncate(r.bodyText)}`);
193
+ const text = JSON.stringify(r.json, null, 2);
194
+ return r.json.resultCode === "200" ? textOk(text) : { ...textErr(text) };
195
+ }
196
+
197
+ function query(params) {
198
+ const qs = new URLSearchParams();
199
+ for (const [k, v] of Object.entries(params)) {
200
+ if (v !== undefined && v !== null) qs.set(k, String(v));
201
+ }
202
+ const s = qs.toString();
203
+ return s ? `?${s}` : "";
204
+ }
205
+
206
+ /** 이 서버가 도는 기기의 파일을 읽는다. 원격 경로가 아니다. */
207
+ async function readLocalFile(filePath) {
208
+ const raw = String(filePath ?? "").trim();
209
+ if (!raw) throw new Error("filePath is required");
210
+ const resolved = path.resolve(raw);
211
+ const st = await fs.stat(resolved);
212
+ if (!st.isFile()) throw new Error(`Not a regular file: ${resolved}`);
213
+ return { buffer: await fs.readFile(resolved), suggestedName: path.basename(resolved) };
214
+ }
215
+
216
+ /** 첨부는 서버가 확장자로 받아 준다. Content-Type 은 예의상 맞춰 보낸다. */
217
+ /**
218
+ * 만든 문서에는 나눠 줄 주소를 붙여 준다. 에이전트의 다음 행동이 바로 그것이다.
219
+ *
220
+ * 서버가 이미 `shareUrl` 을 돌려주면 그것을 쓴다. 서버는 요청이 실제로 들어온 주소를
221
+ * 보고 만들고, 여기서는 `POSTMD_BASE_URL` 을 보고 만든다. 자체 호스팅에서 그 둘이
222
+ * 다르면 값이 갈리는데, 사람에게 건너가는 주소는 서버가 아는 쪽이 맞다.
223
+ *
224
+ * 그래도 이 함수를 남겨 둔다. `shareUrl` 을 돌려주지 않는 구 버전 서버를 가리키는
225
+ * 설정이 있을 수 있고, 그때도 에이전트는 건넬 주소를 받아야 한다.
226
+ */
227
+ function addShareUrl(ctx, data) {
228
+ if (data && typeof data.docCode === "string" && data.docCode && !data.shareUrl) {
229
+ data.shareUrl = `${ctx.base}/share/${encodeURIComponent(data.docCode)}`;
230
+ }
231
+ }
232
+
233
+ function documentForm(a, markdownBuffer) {
234
+ const form = new FormData();
235
+ if (markdownBuffer != null) {
236
+ const fileName = a.fileName || "document.md";
237
+ form.append("file", new Blob([markdownBuffer], { type: "text/markdown" }), fileName);
238
+ }
239
+ if (a.title != null) form.append("title", String(a.title));
240
+ if (a.password != null) form.append("password", String(a.password));
241
+ if (a.viewerStyle != null) form.append("viewerStyle", String(a.viewerStyle));
242
+ return form;
243
+ }
244
+
245
+ async function createDocument(ctx, a, markdownBuffer) {
246
+ const form = documentForm(a, markdownBuffer);
247
+ if (a.groupId != null) form.append("groupId", String(a.groupId));
248
+ const r = await apiFetch(ctx, "/documents", { method: "POST", body: form });
249
+ if (r.json?.resultCode === "200") addShareUrl(ctx, r.json.data);
250
+ return fromEnvelope(r);
251
+ }
252
+
253
+ function passwordHeader(a) {
254
+ const pwd = a.currentPassword ?? a.password;
255
+ return pwd ? { "X-Document-Password": String(pwd) } : {};
256
+ }
257
+
258
+ async function updateDocument(ctx, a, markdownBuffer) {
259
+ const form = documentForm(a, markdownBuffer);
260
+ if (a.clearPassword === true) form.append("clearPassword", "true");
261
+ if ([...form.keys()].length === 0) {
262
+ return textErr("Nothing to update: pass new markdown, or at least one metadata field.");
263
+ }
264
+ /*
265
+ Replacing the content needs a decision about the notes anchored to it. Checked here so the
266
+ caller reads it as a missing argument rather than an HTTP error from the server.
267
+ */
268
+ if (markdownBuffer !== null) {
269
+ if (a.notesOnReplace !== "keep" && a.notesOnReplace !== "abort") {
270
+ return textErr(
271
+ "notesOnReplace is required when replacing the content: keep or abort. " +
272
+ "Notes are located by the text they quote, so replacing the body moves or loses " +
273
+ "where they point. keep replaces anyway; abort refuses when the document has notes " +
274
+ "anchored to its text.",
275
+ );
276
+ }
277
+ form.append("notesOnReplace", a.notesOnReplace);
278
+ }
279
+ const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/update`, {
280
+ method: "POST",
281
+ headers: passwordHeader(a),
282
+ body: form,
283
+ });
284
+ return fromEnvelope(r);
285
+ }
286
+
287
+ const NOTES_ON_REPLACE_PROP = {
288
+ type: "string",
289
+ enum: ["keep", "abort"],
290
+ description:
291
+ "Required when replacing the content. Notes are located by the text they quote, so " +
292
+ "replacing the body moves or loses where they point: a note whose quote is gone loses " +
293
+ "its place in the body, and one whose quote now appears elsewhere points there. " +
294
+ "keep replaces anyway and leaves the notes. abort refuses when the document has notes " +
295
+ "anchored to its text, and the answer says how many.",
296
+ };
297
+
298
+ /** 문서 메타데이터 공통 속성. 만들기·고치기 스키마가 나눠 쓴다. */
299
+ const DOC_META_PROPS = {
300
+ password: { type: "string", description: "Readers must supply this password to see the content." },
301
+ viewerStyle: {
302
+ type: "string",
303
+ description:
304
+ "Viewer theme: readable (default), github, minimal, report, pamphlet or dark. Unknown values fall back to readable.",
305
+ },
306
+ };
307
+
308
+ const TOOL_DEFS = [
309
+ {
310
+ name: "postmd_create_document",
311
+ description:
312
+ "Publish Markdown as a PostMD web page. No API key required — anyone can publish. " +
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 },
319
+ inputSchema: {
320
+ type: "object",
321
+ properties: {
322
+ markdown: {
323
+ type: "string",
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 beside the text; " +
327
+ "the format is at /docs/graph.",
328
+ },
329
+ title: {
330
+ type: "string",
331
+ description: "Shown in the viewer and link previews. Defaults to fileName without .md.",
332
+ },
333
+ fileName: { type: "string", description: "Upload filename, must end in .md. Default document.md." },
334
+ ...DOC_META_PROPS,
335
+ groupId: { type: "number", description: "File the document in this group instead of the default group (needs an API key)." },
336
+ },
337
+ required: ["markdown"],
338
+ },
339
+ },
340
+ {
341
+ name: "postmd_create_document_from_file",
342
+ description:
343
+ "Same as postmd_create_document, but reads the Markdown from filePath on the machine " +
344
+ "running this MCP server — use it for large files instead of pasting the body.",
345
+ annotations: { title: "Publish document from file", destructiveHint: false },
346
+ inputSchema: {
347
+ type: "object",
348
+ properties: {
349
+ filePath: {
350
+ type: "string",
351
+ description: "Path to a .md file on the MCP server host, read as UTF-8. Prefer an absolute path.",
352
+ },
353
+ title: { type: "string", description: "Defaults to the file name without .md." },
354
+ fileName: { type: "string", description: "Upload filename. Defaults to the basename of filePath." },
355
+ ...DOC_META_PROPS,
356
+ groupId: { type: "number", description: "File the document in this group instead of the default group (needs an API key)." },
357
+ },
358
+ required: ["filePath"],
359
+ },
360
+ },
361
+ {
362
+ name: "postmd_create_documents_from_files",
363
+ description:
364
+ "Publish several .md files in one call (bulk upload). Requires an API key with " +
365
+ "documents:write. The outer resultCode is 200 even if some files failed — check " +
366
+ "data.succeeded and each entry in data.results.",
367
+ annotations: { title: "Publish documents from files", destructiveHint: false },
368
+ inputSchema: {
369
+ type: "object",
370
+ properties: {
371
+ filePaths: {
372
+ type: "array",
373
+ items: { type: "string" },
374
+ description: "Paths to .md files on the MCP server host. Each becomes its own document.",
375
+ },
376
+ ...DOC_META_PROPS,
377
+ groupId: { type: "number", description: "File every document in this group instead of the default group." },
378
+ },
379
+ required: ["filePaths"],
380
+ },
381
+ },
382
+ {
383
+ name: "postmd_get_document",
384
+ description:
385
+ "Get document metadata by docCode: title, fileName, hasPassword, " +
386
+ "viewerStyle, timestamps. Public — no API key needed. Content is not included; " +
387
+ "use postmd_get_document_raw for the Markdown source.",
388
+ annotations: { title: "Get document info", readOnlyHint: true },
389
+ inputSchema: {
390
+ type: "object",
391
+ properties: { docCode: { type: "string", description: "Document code, e.g. P-123-456-789." } },
392
+ required: ["docCode"],
393
+ },
394
+ },
395
+ {
396
+ name: "postmd_get_document_raw",
397
+ description:
398
+ "Get the stored Markdown source of a document. Public — no API key needed. " +
399
+ "Password-protected documents need `password`.",
400
+ annotations: { title: "Get document source", readOnlyHint: true },
401
+ inputSchema: {
402
+ type: "object",
403
+ properties: {
404
+ docCode: { type: "string" },
405
+ password: { type: "string", description: "Plain document password, if the document has one." },
406
+ },
407
+ required: ["docCode"],
408
+ },
409
+ },
410
+ {
411
+ name: "postmd_update_document",
412
+ description:
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 },
418
+ inputSchema: {
419
+ type: "object",
420
+ properties: {
421
+ docCode: { type: "string" },
422
+ markdown: {
423
+ type: "string",
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.",
428
+ },
429
+ title: { type: "string" },
430
+ fileName: { type: "string", description: "Upload filename when replacing content. Default document.md." },
431
+ ...DOC_META_PROPS,
432
+ currentPassword: {
433
+ type: "string",
434
+ description: "Current password to authenticate if the document is password-protected.",
435
+ },
436
+ clearPassword: { type: "boolean", description: "true removes the password." },
437
+ notesOnReplace: NOTES_ON_REPLACE_PROP,
438
+ },
439
+ required: ["docCode"],
440
+ },
441
+ },
442
+ {
443
+ name: "postmd_update_document_from_file",
444
+ description:
445
+ "Same as postmd_update_document, but reads the new Markdown from filePath on the " +
446
+ "machine running this MCP server.",
447
+ annotations: { title: "Update document from file", destructiveHint: true },
448
+ inputSchema: {
449
+ type: "object",
450
+ properties: {
451
+ docCode: { type: "string" },
452
+ filePath: {
453
+ type: "string",
454
+ description: "Path to a .md file on the MCP server host, read as UTF-8. Prefer an absolute path.",
455
+ },
456
+ title: { type: "string" },
457
+ fileName: { type: "string", description: "Upload filename. Defaults to the basename of filePath." },
458
+ ...DOC_META_PROPS,
459
+ currentPassword: {
460
+ type: "string",
461
+ description: "Current password to authenticate if the document is password-protected.",
462
+ },
463
+ clearPassword: { type: "boolean", description: "true removes the password." },
464
+ notesOnReplace: NOTES_ON_REPLACE_PROP,
465
+ },
466
+ required: ["docCode", "filePath", "notesOnReplace"],
467
+ },
468
+ },
469
+ {
470
+ name: "postmd_delete_document",
471
+ description:
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 },
475
+ inputSchema: {
476
+ type: "object",
477
+ properties: {
478
+ docCode: { type: "string" },
479
+ password: {
480
+ type: "string",
481
+ description: "Document password if the document has one and you are not authenticated as its owner.",
482
+ },
483
+ },
484
+ required: ["docCode"],
485
+ },
486
+ },
487
+ {
488
+ name: "postmd_list_notes",
489
+ description:
490
+ "List notes and highlights on a document: your own plus every SHARED one, newest " +
491
+ "first. Requires an API key with documents:read. Each note carries mine and " +
492
+ "manageable flags — trust them instead of re-deriving permissions. Pass `password` " +
493
+ "for a password-protected document.",
494
+ annotations: { title: "List notes on a document", readOnlyHint: true },
495
+ inputSchema: {
496
+ type: "object",
497
+ properties: {
498
+ docCode: { type: "string" },
499
+ password: { type: "string", description: "Plain document password, if the document has one." },
500
+ },
501
+ required: ["docCode"],
502
+ },
503
+ },
504
+ {
505
+ name: "postmd_add_note",
506
+ description:
507
+ "Attach a note or highlight to a document. Requires an API key with documents:write. " +
508
+ "Give `content` for a note, `color` alone for a colour-only highlight (then " +
509
+ "`quotedContent` is required — a highlight must point at a passage). Visibility comes " +
510
+ "from ownership: on the key member's own document choose PRIVATE (only they see it) or " +
511
+ "SHARED; on anyone else's document every note is SHARED, so omit scope. Documents " +
512
+ "nobody owns — anonymous uploads and service-owned pages — take no notes at all.",
513
+ annotations: { title: "Add note or highlight", destructiveHint: false },
514
+ inputSchema: {
515
+ type: "object",
516
+ properties: {
517
+ docCode: { type: "string" },
518
+ content: { type: "string", description: "Note text, up to 4000 characters. Omit for a colour-only highlight." },
519
+ quotedContent: {
520
+ type: "string",
521
+ description: "Passage of the body this note points to, up to 4000 characters. Matched by text, so it survives edits elsewhere.",
522
+ },
523
+ scope: { type: "string", description: "PRIVATE (default) or SHARED." },
524
+ color: { type: "string", description: "YELLOW, GREEN, BLUE or PURPLE." },
525
+ textStart: { type: "number", description: "Character offset where the quote starts in the body. Optional; speeds up re-anchoring." },
526
+ password: { type: "string", description: "Plain document password, if the document has one." },
527
+ },
528
+ required: ["docCode"],
529
+ },
530
+ },
531
+ {
532
+ name: "postmd_update_note",
533
+ description:
534
+ "Edit a note you wrote. Requires an API key with documents:write. Omitting scope " +
535
+ "keeps the current one; a scope you do send follows the ownership rule above. The " +
536
+ "note must keep text or a colour.",
537
+ annotations: { title: "Edit note", destructiveHint: false },
538
+ inputSchema: {
539
+ type: "object",
540
+ properties: {
541
+ docCode: { type: "string" },
542
+ noteId: { type: "number" },
543
+ content: { type: "string" },
544
+ quotedContent: { type: "string" },
545
+ scope: { type: "string", description: "PRIVATE or SHARED. Omit to keep the current scope." },
546
+ color: { type: "string", description: "YELLOW, GREEN, BLUE or PURPLE." },
547
+ },
548
+ required: ["docCode", "noteId"],
549
+ },
550
+ },
551
+ {
552
+ name: "postmd_resolve_note",
553
+ description:
554
+ "Mark a note as settled, or undo it with resolved=false. Meaningful on SHARED " +
555
+ "notes; the author or the document owner may set it. Requires an API key with " +
556
+ "documents:write.",
557
+ annotations: { title: "Resolve or reopen note", destructiveHint: false },
558
+ inputSchema: {
559
+ type: "object",
560
+ properties: {
561
+ docCode: { type: "string" },
562
+ noteId: { type: "number" },
563
+ resolved: { type: "boolean", description: "true marks it settled; false reopens it." },
564
+ },
565
+ required: ["docCode", "noteId", "resolved"],
566
+ },
567
+ },
568
+ {
569
+ name: "postmd_delete_note",
570
+ description:
571
+ "Delete a note: your own, or a SHARED note on a document you own. Requires an API " +
572
+ "key with documents:write.",
573
+ annotations: { title: "Delete note", destructiveHint: true },
574
+ inputSchema: {
575
+ type: "object",
576
+ properties: { docCode: { type: "string" }, noteId: { type: "number" } },
577
+ required: ["docCode", "noteId"],
578
+ },
579
+ },
580
+ {
581
+ name: "postmd_list_my_notes",
582
+ description:
583
+ "List every note the key's member wrote, across all documents, with docCode and " +
584
+ "documentTitle beside each one. Requires an API key with documents:read.",
585
+ annotations: { title: "List my notes", readOnlyHint: true },
586
+ inputSchema: { type: "object", properties: {}, required: [] },
587
+ },
588
+ {
589
+ name: "postmd_list_groups",
590
+ description: "List groups the key's member belongs to. Requires an API key with groups:read. Paged.",
591
+ annotations: { title: "List groups", readOnlyHint: true },
592
+ inputSchema: {
593
+ type: "object",
594
+ properties: {
595
+ page: { type: "number", description: "1-based page number." },
596
+ size: { type: "number", description: "Items per page." },
597
+ },
598
+ required: [],
599
+ },
600
+ },
601
+ {
602
+ name: "postmd_list_group_documents",
603
+ description:
604
+ "List documents in a group. Requires an API key with groups:read and documents:read. " +
605
+ "Paged; q searches title and file name (substring, case-insensitive).",
606
+ annotations: { title: "List documents in a group", readOnlyHint: true },
607
+ inputSchema: {
608
+ type: "object",
609
+ properties: {
610
+ groupId: { type: "number" },
611
+ q: { type: "string", description: "Search text for title and file name." },
612
+ sort: {
613
+ type: "string",
614
+ description: "recent (default), oldest, name, name_desc, created or created_asc.",
615
+ },
616
+ page: { type: "number" },
617
+ size: { type: "number" },
618
+ },
619
+ required: ["groupId"],
620
+ },
621
+ },
622
+ {
623
+ name: "postmd_move_document_to_group",
624
+ description:
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 },
628
+ inputSchema: {
629
+ type: "object",
630
+ properties: {
631
+ docCode: { type: "string" },
632
+ groupId: { type: "number" },
633
+ },
634
+ required: ["docCode", "groupId"],
635
+ },
636
+ },
637
+ {
638
+ name: "postmd_create_group",
639
+ description:
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 },
642
+ inputSchema: {
643
+ type: "object",
644
+ properties: {
645
+ name: { type: "string" },
646
+ },
647
+ required: ["name"],
648
+ },
649
+ },
650
+ {
651
+ name: "postmd_update_group",
652
+ description:
653
+ "Rename a group. Owner only. Requires an API key with groups:write.",
654
+ annotations: { title: "Update group", destructiveHint: false },
655
+ inputSchema: {
656
+ type: "object",
657
+ properties: {
658
+ groupId: { type: "number" },
659
+ name: { type: "string" },
660
+ },
661
+ required: ["groupId"],
662
+ },
663
+ },
664
+ {
665
+ name: "postmd_delete_group",
666
+ description:
667
+ "Delete a group. Owner only; the default group cannot be deleted. Documents in it " +
668
+ "are not deleted. Requires an API key with groups:write.",
669
+ annotations: { title: "Delete group", destructiveHint: true },
670
+ inputSchema: {
671
+ type: "object",
672
+ properties: { groupId: { type: "number" } },
673
+ required: ["groupId"],
674
+ },
675
+ },
676
+ ];
677
+
678
+ async function runTool(ctx, name, args) {
679
+ const a = args && typeof args === "object" ? args : {};
680
+
681
+ switch (name) {
682
+ case "postmd_create_document": {
683
+ if (typeof a.markdown !== "string" || a.markdown.length === 0) {
684
+ return textErr("markdown is required: the full document source as one string.");
685
+ }
686
+ return await createDocument(ctx, a, a.markdown);
687
+ }
688
+ case "postmd_create_document_from_file": {
689
+ try {
690
+ const { buffer, suggestedName } = await readLocalFile(a.filePath);
691
+ return await createDocument(ctx, { ...a, fileName: a.fileName ?? suggestedName }, buffer);
692
+ } catch (e) {
693
+ return textErr(e instanceof Error ? e.message : String(e));
694
+ }
695
+ }
696
+ case "postmd_create_documents_from_files": {
697
+ const denied = missingKey(ctx, "documents:write");
698
+ if (denied) return denied;
699
+ if (!Array.isArray(a.filePaths) || a.filePaths.length === 0) {
700
+ return textErr("filePaths is required: one path per document.");
701
+ }
702
+ const form = new FormData();
703
+ try {
704
+ for (const p of a.filePaths) {
705
+ const { buffer, suggestedName } = await readLocalFile(p);
706
+ form.append("files", new Blob([buffer], { type: "text/markdown" }), suggestedName);
707
+ }
708
+ } catch (e) {
709
+ return textErr(e instanceof Error ? e.message : String(e));
710
+ }
711
+ if (a.password != null) form.append("password", String(a.password));
712
+ if (a.viewerStyle != null) form.append("viewerStyle", String(a.viewerStyle));
713
+ if (a.groupId != null) form.append("groupId", String(a.groupId));
714
+ const r = await apiFetch(ctx, "/documents/bulk", { method: "POST", body: form });
715
+ if (r.json?.resultCode === "200" && Array.isArray(r.json.data?.results)) {
716
+ for (const item of r.json.data.results) addShareUrl(ctx, item);
717
+ }
718
+ return fromEnvelope(r);
719
+ }
720
+ case "postmd_get_document": {
721
+ const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/meta`);
722
+ return fromEnvelope(r);
723
+ }
724
+ case "postmd_get_document_raw": {
725
+ const url = `${ctx.base}/api/v1/documents/${encodeURIComponent(a.docCode)}/raw`;
726
+ const headers = {};
727
+ if (ctx.key) headers.Authorization = `Bearer ${ctx.key}`;
728
+ if (a.password) headers["X-Document-Password"] = String(a.password);
729
+ try {
730
+ const res = await fetch(url, { headers });
731
+ const t = await res.text();
732
+ if (!res.ok) return textErr(`HTTP ${res.status}: ${truncate(t)}`);
733
+ return textOk(t);
734
+ } catch (e) {
735
+ const diag = formatNetworkError(e);
736
+ debugStderr(`fetch ${safeUrlForLog(url)} → ${diag}`);
737
+ return textErr(`Request failed: ${diag}`);
738
+ }
739
+ }
740
+ case "postmd_update_document": {
741
+ return await updateDocument(ctx, a, a.markdown ?? null);
742
+ }
743
+ case "postmd_update_document_from_file": {
744
+ try {
745
+ const { buffer, suggestedName } = await readLocalFile(a.filePath);
746
+ return await updateDocument(ctx, { ...a, fileName: a.fileName ?? suggestedName }, buffer);
747
+ } catch (e) {
748
+ return textErr(e instanceof Error ? e.message : String(e));
749
+ }
750
+ }
751
+ case "postmd_delete_document": {
752
+ const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/delete`, {
753
+ method: "POST",
754
+ headers: passwordHeader(a),
755
+ });
756
+ return fromEnvelope(r);
757
+ }
758
+ case "postmd_list_notes": {
759
+ const denied = missingKey(ctx, "documents:read");
760
+ if (denied) return denied;
761
+ const headers = {};
762
+ if (a.password) headers["X-Document-Password"] = String(a.password);
763
+ const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/notes`, { headers });
764
+ return fromEnvelope(r);
765
+ }
766
+ case "postmd_add_note": {
767
+ const denied = missingKey(ctx, "documents:write");
768
+ if (denied) return denied;
769
+ const body = {};
770
+ if (a.content != null) body.content = String(a.content);
771
+ if (a.quotedContent != null) body.quotedContent = String(a.quotedContent);
772
+ if (a.scope != null) body.scope = String(a.scope);
773
+ if (a.color != null) body.color = String(a.color);
774
+ if (a.textStart != null) body.textStart = Number(a.textStart);
775
+ const headers = { "Content-Type": "application/json" };
776
+ if (a.password) headers["X-Document-Password"] = String(a.password);
777
+ const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/notes`, {
778
+ method: "POST",
779
+ headers,
780
+ body: JSON.stringify(body),
781
+ });
782
+ return fromEnvelope(r);
783
+ }
784
+ case "postmd_update_note": {
785
+ const denied = missingKey(ctx, "documents:write");
786
+ if (denied) return denied;
787
+ const body = {};
788
+ if (a.content != null) body.content = String(a.content);
789
+ if (a.quotedContent != null) body.quotedContent = String(a.quotedContent);
790
+ if (a.scope != null) body.scope = String(a.scope);
791
+ if (a.color != null) body.color = String(a.color);
792
+ const r = await apiFetch(
793
+ ctx,
794
+ `/documents/${encodeURIComponent(a.docCode)}/notes/${Number(a.noteId)}/update`,
795
+ { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
796
+ );
797
+ return fromEnvelope(r);
798
+ }
799
+ case "postmd_resolve_note": {
800
+ const denied = missingKey(ctx, "documents:write");
801
+ if (denied) return denied;
802
+ const r = await apiFetch(
803
+ ctx,
804
+ `/documents/${encodeURIComponent(a.docCode)}/notes/${Number(a.noteId)}/resolve`,
805
+ {
806
+ method: "POST",
807
+ headers: { "Content-Type": "application/json" },
808
+ body: JSON.stringify({ resolved: a.resolved === true }),
809
+ }
810
+ );
811
+ return fromEnvelope(r);
812
+ }
813
+ case "postmd_delete_note": {
814
+ const denied = missingKey(ctx, "documents:write");
815
+ if (denied) return denied;
816
+ const r = await apiFetch(
817
+ ctx,
818
+ `/documents/${encodeURIComponent(a.docCode)}/notes/${Number(a.noteId)}/delete`,
819
+ { method: "POST" }
820
+ );
821
+ return fromEnvelope(r);
822
+ }
823
+ case "postmd_list_my_notes": {
824
+ const denied = missingKey(ctx, "documents:read");
825
+ if (denied) return denied;
826
+ const r = await apiFetch(ctx, "/notes");
827
+ return fromEnvelope(r);
828
+ }
829
+ case "postmd_list_groups": {
830
+ const denied = missingKey(ctx, "groups:read");
831
+ if (denied) return denied;
832
+ const r = await apiFetch(ctx, `/groups${query({ page: a.page, size: a.size })}`);
833
+ return fromEnvelope(r);
834
+ }
835
+ case "postmd_list_group_documents": {
836
+ const denied = missingKey(ctx, "groups:read and documents:read");
837
+ if (denied) return denied;
838
+ const qs = query({
839
+ q: a.q,
840
+ sort: a.sort,
841
+ page: a.page,
842
+ size: a.size,
843
+ });
844
+ const r = await apiFetch(ctx, `/groups/${Number(a.groupId)}/documents${qs}`);
845
+ return fromEnvelope(r);
846
+ }
847
+ case "postmd_move_document_to_group": {
848
+ const denied = missingKey(ctx, "documents:write");
849
+ if (denied) return denied;
850
+ const body = { groupId: a.groupId };
851
+ const r = await apiFetch(ctx, `/documents/${encodeURIComponent(a.docCode)}/group`, {
852
+ method: "POST",
853
+ headers: { "Content-Type": "application/json" },
854
+ body: JSON.stringify(body),
855
+ });
856
+ return fromEnvelope(r);
857
+ }
858
+ case "postmd_create_group": {
859
+ const denied = missingKey(ctx, "groups:write");
860
+ if (denied) return denied;
861
+ const body = { name: a.name };
862
+ const r = await apiFetch(ctx, "/groups", {
863
+ method: "POST",
864
+ headers: { "Content-Type": "application/json" },
865
+ body: JSON.stringify(body),
866
+ });
867
+ return fromEnvelope(r);
868
+ }
869
+ case "postmd_update_group": {
870
+ const denied = missingKey(ctx, "groups:write");
871
+ if (denied) return denied;
872
+ const body = {};
873
+ if (a.name != null) body.name = a.name;
874
+ const r = await apiFetch(ctx, `/groups/${Number(a.groupId)}/update`, {
875
+ method: "POST",
876
+ headers: { "Content-Type": "application/json" },
877
+ body: JSON.stringify(body),
878
+ });
879
+ return fromEnvelope(r);
880
+ }
881
+ case "postmd_delete_group": {
882
+ const denied = missingKey(ctx, "groups:write");
883
+ if (denied) return denied;
884
+ const r = await apiFetch(ctx, `/groups/${Number(a.groupId)}/delete`, { method: "POST" });
885
+ return fromEnvelope(r);
886
+ }
887
+ default:
888
+ return textErr(`Unknown tool: ${name}`);
889
+ }
890
+ }
891
+
892
+ /**
893
+ * 도구를 붙인 MCP 서버를 만든다. 전송은 붙이지 않는다 - 부르는 쪽이 고른다.
894
+ *
895
+ * `remote` 는 인터넷에서 아무나 부르는 자리라는 뜻이다. 그 자리에서는 키를 강제로
896
+ * 비운다. 컨테이너 환경에 POSTMD_API_KEY 가 섞여 들어오면 낯선 사람이 발행한 문서가
897
+ * 모두 그 키의 주인 소유로 만들어지기 때문이다(`apiFetch` 가 키를 늘 실어 보낸다).
898
+ */
899
+ export function createMcpServer({ remote = false } = {}) {
900
+ const config = resolveConfig();
901
+ const ctx = remote ? { base: config.base, key: null, remote: true } : { ...config, remote: false };
902
+
903
+ debugStderr(
904
+ `debug on | ${remote ? "remote" : "stdio"} | base ${ctx.base} | ` +
905
+ `key ${ctx.key ? "set" : "not set (publish/read only)"}`
906
+ );
907
+
908
+ const tools = remote ? TOOL_DEFS.filter((t) => REMOTE_TOOLS.has(t.name)) : TOOL_DEFS;
909
+
910
+ const server = new Server(
911
+ { name: "postmd-mcp-server", version: VERSION },
912
+ {
913
+ capabilities: { tools: {} },
914
+ instructions: remote ? INSTRUCTIONS_REMOTE : INSTRUCTIONS_LOCAL,
915
+ }
916
+ );
917
+
918
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
919
+
920
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
921
+ const { name, arguments: args } = request.params;
922
+
923
+ // 목록에서 뺐다고 못 부르는 것이 아니다. 이름을 알면 그냥 부를 수 있으므로 여기서 막는다.
924
+ if (remote && !REMOTE_TOOLS.has(name)) {
925
+ return textErr(
926
+ `${name} is not available on this remote server: it needs either an API key or a ` +
927
+ `file on the server's own disk. Run the stdio server for it — ` +
928
+ `npx -y postmd-mcp-server, with POSTMD_API_KEY set.`
929
+ );
930
+ }
931
+
932
+ try {
933
+ return await runTool(ctx, name, args);
934
+ } catch (e) {
935
+ const msg = formatNetworkError(e);
936
+ debugStderr(`tool ${name} threw: ${msg}`);
937
+ return textErr(msg);
938
+ }
939
+ });
940
+
941
+ return server;
942
+ }
943
+
944
+ export { VERSION, REMOTE_TOOLS, debugStderr };