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