md2wechat-mcp 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,17 @@
1
1
  # 微信 Markdown 转换 MCP
2
2
 
3
- 这个 MCP 服务提供三个工具:
3
+ 这个 MCP 服务提供以下工具:
4
4
  - `convert_markdown_to_wechat_html`: 把 Markdown 转成可用于公众号排版流程的内联样式 HTML。
5
5
  - `list_wechat_themes`: 返回可用主题。
6
- - `open_wechat_html_in_browser`: 接收 `cacheHtmlPath` 直接打开,方便手动 `Cmd+A` / `Cmd+C`。
6
+ - `open_wechat_html_in_browser`: 打开转换后的缓存 HTML(`cacheHtmlPath` 必须来自 `convert_markdown_to_wechat_html` 返回值)。
7
+ - `wechat_get_access_token`: 获取公众号接口调用凭证(读取环境变量)。
8
+ - `wechat_upload_image`: 上传“文章内图片”并返回 URL(不占素材库配额)。
9
+ - `wechat_add_material`: 上传永久素材(`image | voice | video | thumb`)。
10
+ - `wechat_draft_add`: 新增草稿。
11
+ - `wechat_markdown_to_draft`: 一键把 Markdown 转 HTML 并创建草稿。
12
+ - `wechat_draft_update`: 更新草稿指定文章。
13
+ - `wechat_draft_batchget`: 分页查询草稿列表。
14
+ - `wechat_draft_delete`: 删除草稿。
7
15
 
8
16
  当前已支持常见 Markdown 语法(标题、段落、列表、引用、代码块、链接、强调)以及 GFM 风格表格。
9
17
 
@@ -64,14 +72,19 @@ node dist/cli.js ./input.md --theme default
64
72
  - `markdown_path` (string, optional): 传本地 Markdown 文件路径(避免整篇内容走 token)
65
73
  - `theme` (string, optional): `default | tech | warm | apple | wechat-native`
66
74
  - `title` (string, optional)
75
+ - `font_size_preset` (string, optional, default `medium`): `small | medium | large`
76
+ - `access_token` (string, optional): 传入后会自动上传本地图片并替换为微信 URL
67
77
 
68
78
  规则:
69
79
  - `markdown` 和 `markdown_path` 二选一
70
80
  - 如果两者都传,优先使用 `markdown`
81
+ - 顶部一级标题(`# 标题`)会在转换时从正文移除(避免正文重复显示大标题)
71
82
 
72
83
  输出:
73
- - `text` 内容为 HTML 字符串(`<article>...</article>`)
74
- - `meta.cacheHtmlPath` 返回缓存文件路径
84
+ - `content[0].text`:HTML 字符串(`<article>...</article>`)
85
+ - `content[1].text`:可见元信息文本(含 `cacheHtmlPath=...`)
86
+ - `meta.cacheHtmlPath` 返回缓存文件路径(后续 `open_wechat_html_in_browser.cacheHtmlPath` 必须使用这个字段)
87
+ - 兼容说明:若客户端不展示 `meta`,从 `content[1].text` 解析 `cacheHtmlPath=...`
75
88
 
76
89
  ### 2) `list_wechat_themes`
77
90
  输入:空对象
@@ -79,17 +92,303 @@ node dist/cli.js ./input.md --theme default
79
92
 
80
93
  ### 3) `open_wechat_html_in_browser`
81
94
  输入:
82
- - `cacheHtmlPath` (string, required): 已有缓存 HTML 路径,工具会直接打开
95
+ - `cacheHtmlPath` (string, required): 请直接传 `convert_markdown_to_wechat_html` 返回的 `meta.cacheHtmlPath`,不要手猜 `/tmp` 路径
83
96
 
84
97
  输出:
85
98
  - JSON 文本,包含 `ok / opened / cacheHtmlPath`
86
99
  - `meta.cacheHtmlPath` 返回缓存文件路径
87
100
 
101
+ ### 4) `wechat_add_material`
102
+ 对应官方接口:`POST /cgi-bin/material/add_material`
103
+
104
+ 输入:
105
+ - `access_token` (string, required)
106
+ - `type` (string, required): `image | voice | video | thumb`
107
+ - `file_path` (string, required): 本地文件绝对路径
108
+ - `title` (string, optional): `type=video` 时必填
109
+ - `introduction` (string, optional): `type=video` 时必填
110
+
111
+ 输出:
112
+ - `media_id` (string): 新增的永久素材 ID
113
+ - `url` (string, optional): 仅 `image` 类型返回
114
+
115
+ ### 5) `wechat_get_access_token`
116
+ 输入:空对象
117
+
118
+ 依赖环境变量:
119
+ - `WECHAT_APPID`
120
+ - `WECHAT_APPSECRET`
121
+
122
+ 输出:
123
+ - `access_token`
124
+ - `expires_in`
125
+
126
+ ### 6) `wechat_upload_image`
127
+ 对应官方接口:`POST /cgi-bin/media/uploadimg`
128
+
129
+ 输入:
130
+ - `access_token` (string, required)
131
+ - `file_path` (string, required): 本地图片路径,仅支持 `jpg/jpeg/png`
132
+
133
+ 输出:
134
+ - `url` (string): 文章内可用的图片 URL
135
+
136
+ ### 7) `wechat_draft_add`
137
+ 对应官方接口:`POST /cgi-bin/draft/add`
138
+
139
+ 输入:
140
+ - `access_token` (string, required)
141
+ - `articles` (array, required): 至少 1 篇,字段与公众号草稿接口一致,核心字段:
142
+ - `title` (string, required)
143
+ - `content` (string, required): 建议使用 `convert_markdown_to_wechat_html` 生成
144
+ - `author`/`digest`/`content_source_url`/`thumb_media_id`/`need_open_comment`/`only_fans_can_comment` (optional)
145
+
146
+ 输出:
147
+ - `media_id` (string)
148
+
149
+ ### 8) `wechat_draft_update`
150
+ 对应官方接口:`POST /cgi-bin/draft/update`
151
+
152
+ 输入:
153
+ - `access_token` (string, required)
154
+ - `media_id` (string, required)
155
+ - `index` (number, optional, default `0`)
156
+ - `article` (object, required): 同 `wechat_draft_add` 单篇文章结构
157
+
158
+ 输出:
159
+ - `errcode`
160
+ - `errmsg`
161
+
162
+ ### 9) `wechat_markdown_to_draft`
163
+ 一键工具:内部自动执行 `convert_markdown_to_wechat_html` + `wechat_draft_add`。
164
+ 封面自动策略(默认启用,无需传封面参数):
165
+ - 若存在 `title="封面"` 的本地 `jpg/jpeg` 图片:该图上传为封面,并从正文移除
166
+ - 否则:尝试使用首张本地 `jpg/jpeg` 图片作为封面
167
+ - 若无可用图片:不上传封面,草稿仍会创建
168
+ - 顶部一级标题(`# 标题`)会在转换时从正文移除(避免正文重复显示大标题)
169
+
170
+ 输入(核心):
171
+ - `access_token` (string, required)
172
+ - `article_title` (string, optional):草稿标题。未传时按“首个一级标题(H1)→ 文件名(去扩展名)”自动推断
173
+ - `markdown` 或 `markdown_path` (至少一个)
174
+
175
+ 输入(可选):
176
+ - `theme` / `title`
177
+ - `font_size_preset`(可选,`small | medium | large`,默认 `medium`)
178
+ - `thumb_media_id`(可选,显式覆盖自动封面)
179
+ - `author` / `digest` / `content_source_url`
180
+ - `need_open_comment` / `only_fans_can_comment`
181
+
182
+ 说明:
183
+ - `auto_thumb_from_first_image` 参数已移除;封面由 `wechat_markdown_to_draft` 内置策略自动处理。
184
+
185
+ 输出:
186
+ - 草稿创建结果(含 `media_id`)
187
+ - `meta.cacheHtmlPath`(转换缓存路径)
188
+ - `meta.thumbMediaId`(若自动或手动封面可用)
189
+
190
+ ### 10) `wechat_draft_batchget`
191
+ 对应官方接口:`POST /cgi-bin/draft/batchget`
192
+
193
+ 输入:
194
+ - `access_token` (string, required)
195
+ - `offset` (number, optional, default `0`)
196
+ - `count` (number, optional, default `10`, 范围 `1-20`)
197
+ - `no_content` (`0 | 1`, optional, default `0`)
198
+
199
+ 输出:
200
+ - `total_count`
201
+ - `item_count`
202
+ - `item`
203
+
204
+ ### 11) `wechat_draft_delete`
205
+ 对应官方接口:`POST /cgi-bin/draft/delete`
206
+
207
+ 输入:
208
+ - `access_token` (string, required)
209
+ - `media_id` (string, required)
210
+
211
+ 输出:
212
+ - `errcode`
213
+ - `errmsg`
214
+
215
+ ## 典型调用流程
216
+
217
+ 前置:`wechat_markdown_to_draft` 也必须先拿到 `access_token`(可通过 `wechat_get_access_token` 获取)。
218
+
219
+ 1. 推荐直接调用 `wechat_markdown_to_draft` 一步完成转换+建草稿(在已持有 `access_token` 的前提下)
220
+ 2. 或者使用分步模式:`wechat_get_access_token` → `convert_markdown_to_wechat_html` → `wechat_draft_add`
221
+ 3. 封面默认自动处理(`title="封面"` 优先;否则首图);也可手动 `wechat_add_material(type=thumb)` 后在草稿里显式传 `thumb_media_id`
222
+ 4. 使用 `wechat_draft_update / wechat_draft_batchget / wechat_draft_delete` 做后续管理
223
+
224
+ ## 给 AI 的执行规范(建议写入系统提示)
225
+
226
+ 当用户说“把 Markdown 上传到公众号草稿箱”时,必须遵循:
227
+ 1. 必须先调用 `convert_markdown_to_wechat_html`。
228
+ 2. `wechat_draft_add.articles[].content` 必须使用上一步返回的 `content[0].text`。
229
+ 3. 不要把原始 Markdown 直接传给 `wechat_draft_add`。
230
+ 4. `wechat_add_material` 仅用于素材上传(封面/音视频),不能替代 `wechat_draft_add` 创建草稿。
231
+ 5. 需要手动封面时,先调用 `wechat_add_material(type=thumb)`,再将返回 `media_id` 写入 `wechat_draft_add.articles[].thumb_media_id`。
232
+ 6. 调用 `open_wechat_html_in_browser` 时,`cacheHtmlPath` 必须来自 `convert_markdown_to_wechat_html` 返回的 `meta.cacheHtmlPath`。
233
+
234
+ ## MCP 调用示例(可直接复制)
235
+
236
+ ### `wechat_get_access_token`
237
+ ```json
238
+ {
239
+ "name": "wechat_get_access_token",
240
+ "arguments": {}
241
+ }
242
+ ```
243
+
244
+ ### `convert_markdown_to_wechat_html`
245
+ ```json
246
+ {
247
+ "name": "convert_markdown_to_wechat_html",
248
+ "arguments": {
249
+ "markdown_path": "/absolute/path/article.md",
250
+ "theme": "wechat-native",
251
+ "font_size_preset": "small",
252
+ "title": "文章标题",
253
+ "access_token": "ACCESS_TOKEN"
254
+ }
255
+ }
256
+ ```
257
+
258
+ ### `convert_markdown_to_wechat_html` -> `open_wechat_html_in_browser`(字段级映射)
259
+ ```text
260
+ open.arguments.cacheHtmlPath = convert.meta.cacheHtmlPath
261
+ ```
262
+
263
+ 若客户端不展示 `meta`,可从 `convert` 的第二段 `content` 读取:
264
+ ```text
265
+ convert.content[1].text:
266
+ cacheHtmlPath=/abs/path/to/wechat-xxx.html
267
+ ```
268
+
269
+ 提取规则(示例):
270
+ ```text
271
+ cacheHtmlPath = line that starts with "cacheHtmlPath="
272
+ value = text after "="
273
+ ```
274
+
275
+ ```json
276
+ {
277
+ "name": "open_wechat_html_in_browser",
278
+ "arguments": {
279
+ "cacheHtmlPath": "<convert_result.meta.cacheHtmlPath>"
280
+ }
281
+ }
282
+ ```
283
+
284
+ ### `wechat_add_material`
285
+ ```json
286
+ {
287
+ "name": "wechat_add_material",
288
+ "arguments": {
289
+ "access_token": "ACCESS_TOKEN",
290
+ "type": "thumb",
291
+ "file_path": "/absolute/path/cover.jpg"
292
+ }
293
+ }
294
+ ```
295
+
296
+ ### `wechat_upload_image`
297
+ ```json
298
+ {
299
+ "name": "wechat_upload_image",
300
+ "arguments": {
301
+ "access_token": "ACCESS_TOKEN",
302
+ "file_path": "/absolute/path/inline-image.jpg"
303
+ }
304
+ }
305
+ ```
306
+
307
+ ### `wechat_draft_add`
308
+ ```json
309
+ {
310
+ "name": "wechat_draft_add",
311
+ "arguments": {
312
+ "access_token": "ACCESS_TOKEN",
313
+ "articles": [
314
+ {
315
+ "title": "文章标题",
316
+ "author": "作者名",
317
+ "digest": "摘要",
318
+ "content": "<article>...</article>",
319
+ "thumb_media_id": "THUMB_MEDIA_ID",
320
+ "need_open_comment": 1,
321
+ "only_fans_can_comment": 0
322
+ }
323
+ ]
324
+ }
325
+ }
326
+ ```
327
+
328
+ ### `wechat_draft_update`
329
+ ```json
330
+ {
331
+ "name": "wechat_draft_update",
332
+ "arguments": {
333
+ "access_token": "ACCESS_TOKEN",
334
+ "media_id": "DRAFT_MEDIA_ID",
335
+ "index": 0,
336
+ "article": {
337
+ "title": "更新后的标题",
338
+ "content": "<article>...</article>",
339
+ "thumb_media_id": "THUMB_MEDIA_ID"
340
+ }
341
+ }
342
+ }
343
+ ```
344
+
345
+ ### `wechat_markdown_to_draft`
346
+ ```json
347
+ {
348
+ "name": "wechat_markdown_to_draft",
349
+ "arguments": {
350
+ "access_token": "ACCESS_TOKEN",
351
+ "article_title": "文章标题",
352
+ "markdown_path": "/absolute/path/article.md",
353
+ "theme": "wechat-native",
354
+ "font_size_preset": "medium",
355
+ "title": "页面H1标题",
356
+ "author": "作者名",
357
+ "digest": "摘要"
358
+ }
359
+ }
360
+ ```
361
+
362
+ ### `wechat_draft_batchget`
363
+ ```json
364
+ {
365
+ "name": "wechat_draft_batchget",
366
+ "arguments": {
367
+ "access_token": "ACCESS_TOKEN",
368
+ "offset": 0,
369
+ "count": 10,
370
+ "no_content": 1
371
+ }
372
+ }
373
+ ```
374
+
375
+ ### `wechat_draft_delete`
376
+ ```json
377
+ {
378
+ "name": "wechat_draft_delete",
379
+ "arguments": {
380
+ "access_token": "ACCESS_TOKEN",
381
+ "media_id": "DRAFT_MEDIA_ID"
382
+ }
383
+ }
384
+ ```
385
+
88
386
  ## CLI 参数
89
387
 
90
388
  `md2wechat <input> [options]`
91
389
  - `--theme <theme>`
92
390
  - `--title <title>`
391
+ - `--font-size-preset <preset>`
93
392
  - `--out <path>`
94
393
  - `--cache-dir <path>`
95
394
 
package/dist/browser.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { access } from "node:fs/promises";
2
3
  import { promisify } from "node:util";
3
4
  const execFileAsync = promisify(execFile);
4
5
  function getBrowserCommand(filePath) {
@@ -11,6 +12,7 @@ function getBrowserCommand(filePath) {
11
12
  return { command: "xdg-open", args: [filePath] };
12
13
  }
13
14
  export async function openFileInBrowser(filePath, runner = (command, args) => execFileAsync(command, args)) {
15
+ await access(filePath);
14
16
  const { command, args } = getBrowserCommand(filePath);
15
17
  await runner(command, args);
16
18
  }
package/dist/cache.js CHANGED
@@ -16,7 +16,7 @@ export function ensureCacheDir(cwd) {
16
16
  const candidates = [
17
17
  process.env.WECHAT_MCP_CACHE_DIR,
18
18
  join(baseDir, ".cache", "wechat-mcp"),
19
- join(process.cwd(), ".cache", "wechat-mcp"),
19
+ baseDir !== process.cwd() ? join(process.cwd(), ".cache", "wechat-mcp") : undefined,
20
20
  xdgCache ? join(resolve(xdgCache), "wechat-mcp") : undefined,
21
21
  process.platform === "darwin" ? join(homedir(), "Library", "Caches", "wechat-mcp") : join(homedir(), ".cache", "wechat-mcp"),
22
22
  join(tmpdir(), "wechat-mcp")
@@ -37,8 +37,9 @@ export function saveHtmlCache(html, cwd, cacheDir) {
37
37
  const finalDir = cacheDir ? resolve(cacheDir) : ensureCacheDir(cwd);
38
38
  mkdirSync(finalDir, { recursive: true });
39
39
  const now = new Date();
40
- const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`;
41
- const filePath = join(finalDir, `wechat-${stamp}.html`);
40
+ const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}${String(now.getMilliseconds()).padStart(3, "0")}`;
41
+ const randomSuffix = Math.random().toString(36).slice(2, 8);
42
+ const filePath = join(finalDir, `wechat-${stamp}-${randomSuffix}.html`);
42
43
  writeFileSync(filePath, html, "utf8");
43
44
  return filePath;
44
45
  }
package/dist/cli.js CHANGED
@@ -11,6 +11,12 @@ function validateTheme(theme) {
11
11
  }
12
12
  throw new Error(`Invalid theme: ${theme}. Available: ${[...THEME_NAMES].sort().join(", ")}`);
13
13
  }
14
+ function validateFontSizePreset(value) {
15
+ if (value === "small" || value === "medium" || value === "large") {
16
+ return value;
17
+ }
18
+ throw new Error(`Invalid font size preset: ${value}. Available: small, medium, large`);
19
+ }
14
20
  export async function main(argv = process.argv) {
15
21
  const program = new Command();
16
22
  program
@@ -19,6 +25,7 @@ export async function main(argv = process.argv) {
19
25
  .argument("<input>", "Input markdown file path")
20
26
  .option("--theme <theme>", "Rendering theme", "default")
21
27
  .option("--title <title>", "Optional title override")
28
+ .option("--font-size-preset <preset>", "Font size preset: small | medium | large", "medium")
22
29
  .option("--out <path>", "Optional output html path; if omitted, use .cache/wechat-mcp")
23
30
  .option("--cache-dir <path>", "Override cache directory (or set WECHAT_MCP_CACHE_DIR).")
24
31
  .showHelpAfterError();
@@ -27,7 +34,8 @@ export async function main(argv = process.argv) {
27
34
  const markdown = readFileSync(inputPath, "utf8");
28
35
  const opts = program.opts();
29
36
  const theme = validateTheme(opts.theme);
30
- const html = parseMarkdown(markdown, theme, opts.title);
37
+ const fontSizePreset = validateFontSizePreset(opts.fontSizePreset);
38
+ const html = parseMarkdown(markdown, theme, opts.title, fontSizePreset);
31
39
  let outputPath;
32
40
  if (opts.out) {
33
41
  outputPath = resolve(opts.out);
@@ -39,6 +47,7 @@ export async function main(argv = process.argv) {
39
47
  }
40
48
  process.stdout.write(`Input: ${inputPath}\n`);
41
49
  process.stdout.write(`Theme: ${theme}\n`);
50
+ process.stdout.write(`Font size preset: ${fontSizePreset}\n`);
42
51
  process.stdout.write(`Output: ${outputPath}\n`);
43
52
  return 0;
44
53
  }
@@ -1,3 +1,3 @@
1
- import { type Theme } from "./themes.js";
1
+ import { type FontSizePreset, type Theme } from "./themes.js";
2
2
  export declare function inlineFormat(text: string, theme: Theme): string;
3
- export declare function parseMarkdown(md: string, themeName?: string, title?: string): string;
3
+ export declare function parseMarkdown(md: string, themeName?: string, title?: string, fontSizePreset?: FontSizePreset): string;
package/dist/markdown.js CHANGED
@@ -1,4 +1,4 @@
1
- import { THEMES } from "./themes.js";
1
+ import { resolveTheme } from "./themes.js";
2
2
  function escapeHtml(text) {
3
3
  return text
4
4
  .replaceAll("&", "&amp;")
@@ -19,11 +19,15 @@ export function inlineFormat(text, theme) {
19
19
  return stash(`<code style=\"${theme.code_inline}\">${code}</code>`);
20
20
  });
21
21
  // Images must be processed before links (pattern starts with `!`)
22
- escaped = escaped.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, (_m, alt, src) => {
23
- return stash(`<img src=\"${src}\" alt=\"${alt}\" style=\"max-width:100%;height:auto;display:block;margin:0.8em auto;\" />`);
22
+ escaped = escaped.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)(?:\s+(?:"([^"]+)"|'([^']+)'|&quot;([^&]+)&quot;|&#39;([^&]+)&#39;))?\)/g, (_m, alt, src, t1, t2, t3, t4) => {
23
+ const title = t1 ?? t2 ?? t3 ?? t4;
24
+ const titleAttr = title ? ` title=\"${title}\"` : "";
25
+ return stash(`<img src=\"${src}\" alt=\"${alt}\"${titleAttr} style=\"max-width:100%;height:auto;display:block;margin:0.8em auto;\" />`);
24
26
  });
25
- escaped = escaped.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, label, href) => {
26
- return `<a href=\"${href}\" style=\"${theme.a}\">${label}</a>`;
27
+ escaped = escaped.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)(?:\s+(?:"([^"]+)"|'([^']+)'|&quot;([^&]+)&quot;|&#39;([^&]+)&#39;))?\)/g, (_m, label, href, t1, t2, t3, t4) => {
28
+ const title = t1 ?? t2 ?? t3 ?? t4;
29
+ const titleAttr = title ? ` title=\"${title}\"` : "";
30
+ return `<a href=\"${href}\"${titleAttr} style=\"${theme.a}\">${label}</a>`;
27
31
  });
28
32
  escaped = escaped.replace(/\*\*([^*]+)\*\*/g, (_m, content) => {
29
33
  return `<strong style=\"${theme.strong}\">${content}</strong>`;
@@ -60,8 +64,8 @@ function parseColumnAlignment(cell) {
60
64
  return "left";
61
65
  return null;
62
66
  }
63
- export function parseMarkdown(md, themeName = "default", title) {
64
- const theme = THEMES[themeName] ?? THEMES.default;
67
+ export function parseMarkdown(md, themeName = "default", title, fontSizePreset = "medium") {
68
+ const theme = resolveTheme(themeName, fontSizePreset);
65
69
  const lines = md.replaceAll("\r\n", "\n").split("\n");
66
70
  const out = [];
67
71
  if (title) {
package/dist/server.js CHANGED
@@ -8,28 +8,55 @@ import pkg from "../package.json" with { type: "json" };
8
8
  const SERVER_NAME = pkg.name;
9
9
  const SERVER_VERSION = pkg.version;
10
10
  const themeSchema = z.enum(["default", "tech", "warm", "apple", "wechat-native"]);
11
+ const fontSizePresetSchema = z.enum(["small", "medium", "large"]);
11
12
  const SERVER_INSTRUCTIONS = `
12
13
  ## 发布 Markdown 图文到微信公众号草稿箱
13
14
 
14
15
  前置条件:MCP server 配置环境变量 WECHAT_APPID 和 WECHAT_APPSECRET。
15
16
 
16
- 完整工作流:
17
+ 严格执行规则(非常重要):
18
+ 1) 用户要求“发布/上传 Markdown 到草稿箱”时,必须先调用 convert_markdown_to_wechat_html,再调用 wechat_draft_add。
19
+ 2) wechat_draft_add.articles[].content 必须使用 convert_markdown_to_wechat_html 返回的 content[0].text,不能直接使用原始 Markdown。
20
+ 3) wechat_add_material 只用于上传素材(如封面),不能替代 wechat_draft_add 创建草稿。
21
+ 4) 封面素材请通过一键工具自动处理,或手动调用 wechat_add_material(type=thumb) 后写入 wechat_draft_add.articles[].thumb_media_id。
22
+ 5) 若要预览/打开 HTML,open_wechat_html_in_browser.cacheHtmlPath 必须使用 convert_markdown_to_wechat_html 返回的 meta.cacheHtmlPath,不要猜测临时路径。
23
+ 6) 若客户端不展示 meta,必须从 convert_markdown_to_wechat_html 的 content[1].text 解析 cacheHtmlPath=...,不得用“最近文件”或路径猜测替代。
24
+
25
+ 完整工作流(按顺序):
26
+
27
+ 推荐:可直接使用一键工具 wechat_markdown_to_draft 完成 Step 2/5 的核心链路(Step 1 仍需先获取 access_token)。
28
+ 一键工具会自动处理封面:
29
+ - 若存在 title 为“封面”的本地 JPG/JPEG 图片,则该图作为封面并从正文移除;
30
+ - 否则尝试用首张本地 JPG/JPEG 作为封面;
31
+ - 若无可用图片则不上传封面。
17
32
 
18
33
  Step 1 — 获取 access_token
19
34
  wechat_get_access_token() → { access_token, expires_in }
20
35
  同一次任务保存复用,无需重复获取(有效期 7200 秒)。
21
36
 
22
37
  Step 2 — 转换 Markdown 并自动上传本地图片
23
- convert_markdown_to_wechat_html(markdown_path, access_token, theme?, title?)
38
+ convert_markdown_to_wechat_html(markdown_path, access_token, theme?, title?, font_size_preset?)
24
39
  传入 access_token 后,![alt](./local/path) 形式的本地图片自动上传并替换为微信 CDN URL。
40
+ 返回:content[0].text(HTML)、content[1].text(可见元信息)、meta.cacheHtmlPath(缓存文件路径)。
25
41
 
26
42
  Step 3(可选)— 上传文章内嵌图片
27
43
  wechat_upload_image(access_token, file_path) → { url }
28
44
  返回的 url 可用于文章 HTML 中的 <img src>,但不能用作封面 thumb_media_id。
29
45
 
30
- Step 4 — 新增草稿
46
+ Step 4(可选)— 上传永久素材(封面图/视频等)
47
+ wechat_add_material(access_token, type, file_path, title?, introduction?) → { media_id, url? }
48
+ type: image | voice | video | thumb
49
+ 当 type=video 时需要提供 title 和 introduction;返回的 media_id 可用于 thumb_media_id 或其他素材场景。
50
+
51
+ Step 5 — 新增草稿
31
52
  wechat_draft_add(access_token, articles: [{ title, content, author?, digest?, thumb_media_id?, ... }])
32
- content 使用 Step 2 返回的 HTML。返回 media_id 可用于后续更新或删除。
53
+ content 必须使用 Step 2 返回的 content[0].text。返回 media_id 可用于后续更新或删除。
54
+
55
+ 标准映射(必须遵循):
56
+ draft.content = convert.content[0].text
57
+ draft.thumb_media_id = 使用 wechat_add_material(type=thumb) 返回的 media_id(或一键工具自动返回的封面)
58
+ open.cacheHtmlPath = convert.meta.cacheHtmlPath
59
+ fallback: open.cacheHtmlPath = parse(convert.content[1].text where line starts with "cacheHtmlPath=")
33
60
 
34
61
  其他工具:
35
62
  wechat_draft_batchget — 分页查询草稿列表
@@ -41,12 +68,13 @@ Step 4 — 新增草稿
41
68
  export function createServer() {
42
69
  const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { instructions: SERVER_INSTRUCTIONS });
43
70
  server.registerTool("convert_markdown_to_wechat_html", {
44
- description: "Convert Markdown to WeChat-friendly HTML with inline styles for copy/paste publishing workflows.",
71
+ description: "Convert Markdown to WeChat-friendly HTML. Output mapping: content[0].text is article HTML; meta.cacheHtmlPath is the cached file path for open_wechat_html_in_browser.cacheHtmlPath.",
45
72
  inputSchema: {
46
73
  markdown: z.string().optional().describe("Source markdown text. If both markdown and markdown_path are provided, markdown is used."),
47
74
  markdown_path: z.string().optional().describe("Local markdown file path. Use this to avoid sending full content through tokens."),
48
75
  theme: themeSchema.optional().default("default").describe("Theme name: default | tech | warm | apple | wechat-native"),
49
76
  title: z.string().optional().describe("Optional article title rendered as h1"),
77
+ font_size_preset: fontSizePresetSchema.optional().default("medium").describe("Font size preset: small | medium | large"),
50
78
  access_token: z.string().optional().describe("WeChat API access token. When provided, local images referenced as ![alt](./path) are automatically uploaded to WeChat CDN and replaced with permanent URLs.")
51
79
  }
52
80
  }, async (args) => handleToolCall("convert_markdown_to_wechat_html", args));
@@ -55,9 +83,9 @@ export function createServer() {
55
83
  inputSchema: {}
56
84
  }, async () => handleToolCall("list_wechat_themes", {}));
57
85
  server.registerTool("open_wechat_html_in_browser", {
58
- description: "Open cached HTML path directly in the default browser for manual copy workflows.",
86
+ description: "Open cached HTML in browser for manual copy workflows. Pass convert_markdown_to_wechat_html result meta.cacheHtmlPath; do not guess temp paths.",
59
87
  inputSchema: {
60
- cacheHtmlPath: z.string().describe("Existing cached HTML file path to open directly")
88
+ cacheHtmlPath: z.string().describe("Use convert_markdown_to_wechat_html returned meta.cacheHtmlPath directly")
61
89
  }
62
90
  }, async (args) => handleToolCall("open_wechat_html_in_browser", args));
63
91
  const articleSchema = z.object({
@@ -76,12 +104,30 @@ export function createServer() {
76
104
  inputSchema: {}
77
105
  }, async () => handleToolCall("wechat_get_access_token", {}));
78
106
  server.registerTool("wechat_draft_add", {
79
- description: "Add a new draft to WeChat Official Account draft box. Returns media_id for the created draft.",
107
+ description: "Add a new draft to WeChat Official Account draft box. articles[].content must be HTML from convert_markdown_to_wechat_html content[0].text.",
80
108
  inputSchema: {
81
109
  access_token: z.string().describe("WeChat API access token from wechat_get_access_token"),
82
110
  articles: z.array(articleSchema).min(1).describe("Array of articles to include in the draft (usually 1)")
83
111
  }
84
112
  }, async (args) => handleToolCall("wechat_draft_add", args));
113
+ server.registerTool("wechat_markdown_to_draft", {
114
+ description: "One-shot tool: convert markdown to WeChat HTML and create draft. Cover is handled automatically (title=封面 preferred, else first local JPG/JPEG).",
115
+ inputSchema: {
116
+ access_token: z.string().describe("WeChat API access token from wechat_get_access_token"),
117
+ article_title: z.string().optional().describe("Draft article title. Fallback: first markdown H1, then markdown file name."),
118
+ markdown: z.string().optional().describe("Source markdown text. If both markdown and markdown_path are provided, markdown is used."),
119
+ markdown_path: z.string().optional().describe("Local markdown file path"),
120
+ theme: themeSchema.optional().default("default").describe("Theme name: default | tech | warm | apple | wechat-native"),
121
+ title: z.string().optional().describe("Optional rendered h1 title for HTML"),
122
+ font_size_preset: fontSizePresetSchema.optional().default("medium").describe("Font size preset: small | medium | large"),
123
+ thumb_media_id: z.string().optional().describe("Optional explicit thumb media id override."),
124
+ author: z.string().optional().describe("Author name"),
125
+ digest: z.string().optional().describe("Article summary"),
126
+ content_source_url: z.string().optional().describe("Original article URL"),
127
+ need_open_comment: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable comments: 1=yes, 0=no"),
128
+ only_fans_can_comment: z.union([z.literal(0), z.literal(1)]).optional().describe("Only followers can comment: 1=yes, 0=no")
129
+ }
130
+ }, async (args) => handleToolCall("wechat_markdown_to_draft", args));
85
131
  server.registerTool("wechat_draft_update", {
86
132
  description: "Update an existing draft in WeChat Official Account draft box by media_id.",
87
133
  inputSchema: {
@@ -98,6 +144,16 @@ export function createServer() {
98
144
  file_path: z.string().describe("Absolute local path to the image file (JPG or PNG, max 1MB)")
99
145
  }
100
146
  }, async (args) => handleToolCall("wechat_upload_image", args));
147
+ server.registerTool("wechat_add_material", {
148
+ description: "Upload permanent material to WeChat material library (image/voice/video/thumb). Returns media_id and image url when type=image.",
149
+ inputSchema: {
150
+ access_token: z.string().describe("WeChat API access token from wechat_get_access_token"),
151
+ type: z.enum(["image", "voice", "video", "thumb"]).describe("Permanent material type"),
152
+ file_path: z.string().describe("Absolute local file path"),
153
+ title: z.string().optional().describe("Video title. Required when type=video"),
154
+ introduction: z.string().optional().describe("Video introduction. Required when type=video")
155
+ }
156
+ }, async (args) => handleToolCall("wechat_add_material", args));
101
157
  server.registerTool("wechat_draft_batchget", {
102
158
  description: "Query draft list from WeChat Official Account draft box with pagination.",
103
159
  inputSchema: {
@@ -125,7 +181,15 @@ MCP server that converts Markdown to WeChat-friendly HTML.
125
181
  Tools:
126
182
  convert_markdown_to_wechat_html Convert Markdown to inline-styled HTML
127
183
  list_wechat_themes List available themes
128
- open_wechat_html_in_browser Open cached HTML in browser
184
+ open_wechat_html_in_browser Open cached HTML in browser (use meta.cacheHtmlPath from convert result)
185
+ wechat_get_access_token Get WeChat access token from env
186
+ wechat_upload_image Upload inline article image
187
+ wechat_add_material Upload permanent material
188
+ wechat_draft_add Add draft
189
+ wechat_markdown_to_draft One-shot markdown to draft
190
+ wechat_draft_update Update draft
191
+ wechat_draft_batchget Query draft list
192
+ wechat_draft_delete Delete draft
129
193
 
130
194
  MCP config:
131
195
  {
package/dist/themes.d.ts CHANGED
@@ -18,5 +18,7 @@ export type Theme = {
18
18
  th: string;
19
19
  td: string;
20
20
  };
21
+ export type FontSizePreset = "small" | "medium" | "large";
21
22
  export declare const THEMES: Record<string, Theme>;
22
23
  export declare const THEME_NAMES: string[];
24
+ export declare function resolveTheme(themeName: string, fontSizePreset?: FontSizePreset): Theme;