writedock-mcp 0.4.11 → 0.4.13

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.
@@ -4,7 +4,7 @@
4
4
  "name": "写作台 WriteDock",
5
5
  "short_name": "写作台",
6
6
  "description": "连接自己的 AI agent,通过 MCP 发布小红书长文、图文和视频,微博图文、视频及长文章,知乎、百家号、B站专栏文章与抖音图文视频。",
7
- "version": "0.4.11",
7
+ "version": "0.4.13",
8
8
  "minimum_chrome_version": "116",
9
9
  "icons": {
10
10
  "16": "assets/icon-16.png",
@@ -1 +1 @@
1
- import './assets/index.ts-CSY6FJ6h.js';
1
+ import './assets/index.ts-BeiBzFaR.js';
@@ -6,7 +6,7 @@
6
6
  <meta name="color-scheme" content="light" />
7
7
  <title>写作台 · WriteDock</title>
8
8
  <link rel="icon" type="image/png" href="/assets/icon-48.png" />
9
- <script type="module" crossorigin src="/assets/editor-DSj8NlXD.js"></script>
9
+ <script type="module" crossorigin src="/assets/editor-CMMMIxFa.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/purify.es-1yEQDPgc.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/react-vendor-BrXlnJmF.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/task-utils-DwzGcJkm.js">
@@ -1,4 +1,4 @@
1
- WriteDock 安装与更新 v0.4.11
1
+ WriteDock 安装与更新 v0.4.13
2
2
 
3
3
  首次使用
4
4
  1. 把这句话发给你的 agent:帮我安装 WriteDock:先检查电脑上有没有 Node.js 22 或更高版本,没有就帮我安装,需要我操作时告诉我;然后运行 npx -y writedock-mcp@latest setup,最后告诉我怎么重启你。
package/index.cjs CHANGED
@@ -29740,7 +29740,7 @@ var MediaStore = class {
29740
29740
  // package.json
29741
29741
  var package_default = {
29742
29742
  name: "@writedock/mcp-server",
29743
- version: "0.4.11",
29743
+ version: "0.4.13",
29744
29744
  private: true,
29745
29745
  type: "module",
29746
29746
  description: "WriteDock local MCP server for the browser extension",
@@ -29924,6 +29924,14 @@ function mcpReleaseInfo() {
29924
29924
  update_checking: !!pending
29925
29925
  };
29926
29926
  }
29927
+ function updateNotice(input) {
29928
+ const notices = [];
29929
+ if (input.bundle && (!input.extension || newerVersion(input.bundle, input.extension))) {
29930
+ notices.push(`WriteDock \u6269\u5C55\u6709\u65B0\u7248\u672C v${input.bundle}${input.extension ? `\uFF08\u5F53\u524D v${input.extension}\uFF09` : ""}\uFF0C\u9700\u8981\u7528\u6237\u81EA\u5DF1\u70B9\u4E00\u4E0B\u624D\u4F1A\u66F4\u65B0\uFF1A\u70B9\u6D4F\u89C8\u5668\u53F3\u4E0A\u89D2\u7684\u62FC\u56FE\u56FE\u6807\uFF0C\u70B9 WriteDock\uFF0C\u8FDB\u5165\u201C\u8FDE\u63A5 agent\u201D\uFF0C\u70B9\u201C\u66F4\u65B0\u6269\u5C55\u201D\u3002\u8FDE\u63A5\u548C\u7A3F\u4EF6\u90FD\u4F1A\u4FDD\u7559\u3002`);
29931
+ }
29932
+ if (input.mcpLatest && input.mcp && newerVersion(input.mcpLatest, input.mcp)) notices.push(`WriteDock \u670D\u52A1\u6709\u65B0\u7248\u672C v${input.mcpLatest}\uFF1A\u91CD\u542F agent \u5C31\u4F1A\u66F4\u65B0\u3002`);
29933
+ return notices.length ? `${notices.join("")}\u8BF7\u5728\u6C47\u62A5\u7ED3\u679C\u65F6\u628A\u8FD9\u4E9B\u6B65\u9AA4\u539F\u6837\u544A\u8BC9\u7528\u6237\u4E00\u6B21\u3002\u8FD9\u4E0D\u5F71\u54CD\u672C\u6B21\u7ED3\u679C\uFF0C\u4E0D\u8981\u7B49\u7528\u6237\u66F4\u65B0\uFF0C\u4E5F\u4E0D\u8981\u56E0\u6B64\u91CD\u53D1\u3002` : void 0;
29934
+ }
29927
29935
 
29928
29936
  // src/bridge.ts
29929
29937
  var LIMIT = 32 * 1024 * 1024;
@@ -29953,14 +29961,18 @@ var LocalBridge = class {
29953
29961
  starting;
29954
29962
  primary = false;
29955
29963
  extensionId;
29964
+ extensionVersion;
29956
29965
  seenNonces = /* @__PURE__ */ new Map();
29957
29966
  extensionToken;
29967
+ lastWork = 0;
29968
+ lastSeen = Date.now();
29958
29969
  media;
29959
29970
  pending = /* @__PURE__ */ new Map();
29960
29971
  port;
29961
29972
  async start() {
29962
29973
  if (this.starting) return this.starting;
29963
- this.starting = this.listen().catch(async (error2) => {
29974
+ this.starting = Promise.resolve(this.options.ensureService?.()).catch(() => {
29975
+ }).then(() => this.listen()).catch(async (error2) => {
29964
29976
  if (error2.code !== "EADDRINUSE") throw error2;
29965
29977
  this.server = void 0;
29966
29978
  this.sockets = void 0;
@@ -30007,6 +30019,31 @@ var LocalBridge = class {
30007
30019
  }, 2e4);
30008
30020
  this.heartbeat.unref();
30009
30021
  }
30022
+ get isPrimary() {
30023
+ return this.primary;
30024
+ }
30025
+ notice() {
30026
+ if (this.client?.readyState !== import_websocket.default.OPEN) return void 0;
30027
+ const release = mcpReleaseInfo();
30028
+ return updateNotice({ bundle: release.extension_bundle_version, extension: this.extensionVersion, mcpLatest: release.latest_version, mcp: release.mcp_version });
30029
+ }
30030
+ connectionFields() {
30031
+ const connected = this.client?.readyState === import_websocket.default.OPEN, notice = this.notice();
30032
+ return { connected, extensionId: this.extensionId, ...this.extensionVersion ? { extension_version: this.extensionVersion } : {}, ...notice ? { update_notice: notice } : {}, protocol: "writedock/1", ...mcpReleaseInfo() };
30033
+ }
30034
+ /** How long nothing has needed this service. An open extension connection counts as needed. */
30035
+ idleFor() {
30036
+ if (this.client?.readyState === import_websocket.default.OPEN) this.lastSeen = Date.now();
30037
+ return Date.now() - this.lastSeen;
30038
+ }
30039
+ /** Asks the running service to step aside for a newer one. It says no while it has work. */
30040
+ async askServiceToStop() {
30041
+ try {
30042
+ return (await this.httpCall("/stop", {})).accepted === true;
30043
+ } catch {
30044
+ return false;
30045
+ }
30046
+ }
30010
30047
  validHost(host) {
30011
30048
  return host === `127.0.0.1:${this.port}` || host === `localhost:${this.port}`;
30012
30049
  }
@@ -30018,6 +30055,7 @@ var LocalBridge = class {
30018
30055
  const previousClient = this.client;
30019
30056
  this.client = void 0;
30020
30057
  this.extensionId = void 0;
30058
+ this.extensionVersion = void 0;
30021
30059
  for (const value of this.pending.values()) {
30022
30060
  clearTimeout(value.timer);
30023
30061
  value.reject(new Error(reason));
@@ -30058,6 +30096,7 @@ var LocalBridge = class {
30058
30096
  clearTimeout(timer);
30059
30097
  this.client = ws;
30060
30098
  this.extensionId = String(message.extensionId || "");
30099
+ this.extensionVersion = typeof message.extensionVersion === "string" && message.extensionVersion.length <= 40 ? message.extensionVersion : void 0;
30061
30100
  ws.send(JSON.stringify({ type: "ready", ...mcpReleaseInfo() }));
30062
30101
  return;
30063
30102
  }
@@ -30084,6 +30123,7 @@ var LocalBridge = class {
30084
30123
  if (this.client !== ws) return;
30085
30124
  this.client = void 0;
30086
30125
  this.extensionId = void 0;
30126
+ this.extensionVersion = void 0;
30087
30127
  for (const value of this.pending.values()) {
30088
30128
  clearTimeout(value.timer);
30089
30129
  value.reject(new Error("\u6269\u5C55\u8FDE\u63A5\u5DF2\u65AD\u5F00\u3002\u82E5\u5DF2\u63D0\u4EA4\u540C\u6B65\uFF0C\u8BF7\u6309\u4EFB\u52A1 ID \u67E5\u8BE2\u7ED3\u679C\uFF0C\u4E0D\u8981\u66F4\u6362 request_id \u81EA\u52A8\u91CD\u53D1\u3002"));
@@ -30109,6 +30149,7 @@ var LocalBridge = class {
30109
30149
  }
30110
30150
  const assetPath = /^\/assets\/([a-f0-9-]{36})$/.exec(req.url || "");
30111
30151
  if (assetPath && this.validHost(req.headers.host) && (!req.headers.origin || /^chrome-extension:\/\/[a-p]{32}$/.test(req.headers.origin))) {
30152
+ this.lastWork = this.lastSeen = Date.now();
30112
30153
  await this.media.serve(req, res, assetPath[1]);
30113
30154
  return;
30114
30155
  }
@@ -30139,10 +30180,18 @@ var LocalBridge = class {
30139
30180
  for (const [key2, expires] of this.seenNonces) if (expires < now) this.seenNonces.delete(key2);
30140
30181
  this.seenNonces.set(nonce, now + 3e4);
30141
30182
  responseNonce = nonce;
30183
+ if (req.method === "POST" && req.url === "/stop") {
30184
+ const accepted = this.pending.size === 0 && Date.now() - this.lastWork > 12e4;
30185
+ send(200, { accepted });
30186
+ if (accepted) setImmediate(() => this.options.onStopRequested?.());
30187
+ return;
30188
+ }
30189
+ this.lastSeen = Date.now();
30142
30190
  if (req.method === "GET" && req.url === "/status") {
30143
- send(200, { connected: this.client?.readyState === import_websocket.default.OPEN, extensionId: this.extensionId, protocol: "writedock/1", ...mcpReleaseInfo() });
30191
+ send(200, this.connectionFields());
30144
30192
  return;
30145
30193
  }
30194
+ this.lastWork = Date.now();
30146
30195
  if (req.method === "POST" && req.url === "/assets/register") {
30147
30196
  const input2 = JSON.parse(body);
30148
30197
  if (typeof input2.path !== "string" || !["image", "video"].includes(input2.kind)) throw new Error("\u7D20\u6750\u53C2\u6570\u4E0D\u6B63\u786E");
@@ -30179,7 +30228,7 @@ var LocalBridge = class {
30179
30228
  }
30180
30229
  async status() {
30181
30230
  await this.start();
30182
- if (this.primary) return { connected: this.client?.readyState === import_websocket.default.OPEN, extensionId: this.extensionId, protocol: "writedock/1", ...mcpReleaseInfo() };
30231
+ if (this.primary) return this.connectionFields();
30183
30232
  try {
30184
30233
  return await this.httpCall("/status");
30185
30234
  } catch (error2) {
@@ -30187,7 +30236,7 @@ var LocalBridge = class {
30187
30236
  this.starting = void 0;
30188
30237
  await this.start();
30189
30238
  if (this.primary) return { connected: false, protocol: "writedock/1", ...mcpReleaseInfo() };
30190
- throw error2;
30239
+ return this.httpCall("/status");
30191
30240
  }
30192
30241
  }
30193
30242
  async request(method, params = {}) {
@@ -30202,7 +30251,10 @@ var LocalBridge = class {
30202
30251
  }
30203
30252
  async registerMedia(path, kind) {
30204
30253
  await this.status();
30205
- if (this.primary) return this.media.register(path, kind);
30254
+ if (this.primary) {
30255
+ this.lastWork = this.lastSeen = Date.now();
30256
+ return this.media.register(path, kind);
30257
+ }
30206
30258
  return await this.httpCall("/assets/register", { path, kind });
30207
30259
  }
30208
30260
  async requestConnected(method, params) {
@@ -30220,6 +30272,9 @@ var LocalBridge = class {
30220
30272
  this.pending.delete(id);
30221
30273
  reject(error2);
30222
30274
  });
30275
+ }).then((result) => {
30276
+ const notice = result && typeof result === "object" && result.finished === true ? this.notice() : void 0;
30277
+ return notice ? { ...result, update_notice: notice } : result;
30223
30278
  });
30224
30279
  }
30225
30280
  async close() {
@@ -50329,7 +50384,7 @@ var postSource = {
50329
50384
  weibo_video_creation: external_exports.enum(["original", "secondary", "repost"]).optional().describe("\u5FAE\u535A\u72EC\u7ACB\u89C6\u9891\u5FC5\u586B\uFF1Aoriginal \u539F\u521B\u3001secondary \u4E8C\u521B\u3001repost \u8F6C\u8F7D\u3002\u5FC5\u987B\u6839\u636E\u7528\u6237\u5B9E\u9645\u5185\u5BB9\u660E\u786E\u9009\u62E9\uFF0C\u4E0D\u5F97\u9ED8\u8BA4\u539F\u521B\u3002\u5176\u4ED6\u5E73\u53F0\u4E0D\u4F7F\u7528\u6B64\u5B57\u6BB5\uFF1B\u6765\u6E90\u3001\u5185\u5BB9\u58F0\u660E\u6216\u534F\u8BAE\u9700\u8865\u5145\u65F6\u8F6C\u4EBA\u5DE5\u5904\u7406"),
50330
50385
  base_directory: external_exports.string().optional().describe("\u4F7F\u7528\u76F8\u5BF9\u7D20\u6750\u8DEF\u5F84\u65F6\uFF0C\u660E\u786E\u6307\u5B9A\u7D20\u6750\u76EE\u5F55")
50331
50386
  };
50332
- var agentWorkflow = "Agent \u53EA\u901A\u8FC7 WriteDock MCP \u6295\u7A3F\u548C\u67E5\u8BE2\u56DE\u6267\uFF0C\u4E0D\u4F9D\u8D56\u4E5F\u4E0D\u8981\u8C03\u7528\u6D4F\u89C8\u5668\u3001Playwright \u6216\u5176\u4ED6\u7F51\u9875\u5DE5\u5177\u3002\u6309\u7ED3\u679C\u7684 next_action \u64CD\u4F5C\uFF1Apoll_task \u7528\u539F task_id/request_id \u67E5\u8BE2 get_sync_task\uFF1Breport_result \u76F4\u63A5\u5411\u7528\u6237\u6C47\u62A5\u9010\u5E73\u53F0\u72B6\u6001\u548C\u94FE\u63A5\u3002submitted/published \u5DF2\u6709\u6269\u5C55\u786E\u8BA4\uFF0C\u4E0D\u518D\u6253\u5F00\u7F51\u9875\u9A8C\u8BC1\uFF1B\u9A8C\u8BC1\u7801\u3001\u56FE\u5F62\u9A8C\u8BC1\u3001\u534F\u8BAE\u3001\u53D1\u5E03\u8BBE\u7F6E\u6216 unknown/interrupted \u90FD\u5C06\u539F\u56E0\u548C\u94FE\u63A5\u4EA4\u7ED9\u7528\u6237\u81EA\u884C\u70B9\u51FB\u5904\u7406\uFF0C\u4E0D\u81EA\u52A8\u91CD\u53D1\u3001\u4E0D\u6362 request_id\u3002\u4EBA\u5DE5\u63A5\u7BA1\u4E0D\u4EE3\u8868\u5DF2\u7ECF\u53D1\u5E03\u3002\u552F\u4E00\u4F8B\u5916\u662F user_actions \u7684 type=old_draft\uFF1A\u5E73\u53F0\u6709\u4E00\u4EFD\u7528\u6237\u4E0A\u6B21\u6CA1\u53D1\u5B8C\u7684\u5185\u5BB9\uFF0C\u8FD9\u7BC7\u5C1A\u672A\u63D0\u4EA4\u3002\u5FC5\u987B\u5148\u95EE\u7528\u6237\u662F\u5426\u653E\u5F03\u90A3\u4EFD\u65E7\u5185\u5BB9\uFF0C\u4E0D\u5F97\u66FF\u7528\u6237\u51B3\u5B9A\uFF1B\u7528\u6237\u660E\u786E\u540C\u610F\u540E\uFF0C\u624D\u7528\u65B0\u7684 request_id \u53EA\u5BF9\u8BE5\u5E73\u53F0\u518D\u8C03\u7528 publish\uFF0C\u5E76\u4F20 discard_old_drafts\uFF1B\u7528\u6237\u4E0D\u540C\u610F\u5C31\u4FDD\u7559\u65E7\u5185\u5BB9\uFF0C\u544A\u8BC9\u7528\u6237\u8FD9\u4E2A\u5E73\u53F0\u6CA1\u6709\u53D1\u3002";
50387
+ var agentWorkflow = "Agent \u53EA\u901A\u8FC7 WriteDock MCP \u6295\u7A3F\u548C\u67E5\u8BE2\u56DE\u6267\uFF0C\u4E0D\u4F9D\u8D56\u4E5F\u4E0D\u8981\u8C03\u7528\u6D4F\u89C8\u5668\u3001Playwright \u6216\u5176\u4ED6\u7F51\u9875\u5DE5\u5177\u3002\u6309\u7ED3\u679C\u7684 next_action \u64CD\u4F5C\uFF1Apoll_task \u7528\u539F task_id/request_id \u67E5\u8BE2 get_sync_task\uFF1Breport_result \u76F4\u63A5\u5411\u7528\u6237\u6C47\u62A5\u9010\u5E73\u53F0\u72B6\u6001\u548C\u94FE\u63A5\u3002submitted/published \u5DF2\u6709\u6269\u5C55\u786E\u8BA4\uFF0C\u4E0D\u518D\u6253\u5F00\u7F51\u9875\u9A8C\u8BC1\uFF1B\u9A8C\u8BC1\u7801\u3001\u56FE\u5F62\u9A8C\u8BC1\u3001\u534F\u8BAE\u3001\u53D1\u5E03\u8BBE\u7F6E\u6216 unknown/interrupted \u90FD\u5C06\u539F\u56E0\u548C\u94FE\u63A5\u4EA4\u7ED9\u7528\u6237\u81EA\u884C\u70B9\u51FB\u5904\u7406\uFF0C\u4E0D\u81EA\u52A8\u91CD\u53D1\u3001\u4E0D\u6362 request_id\u3002\u4EBA\u5DE5\u63A5\u7BA1\u4E0D\u4EE3\u8868\u5DF2\u7ECF\u53D1\u5E03\u3002\u7ED3\u679C\u91CC\u6709 update_notice \u65F6\uFF0C\u5728\u6C47\u62A5\u7ED3\u679C\u672B\u5C3E\u987A\u5E26\u544A\u8BC9\u7528\u6237\u4E00\u6B21\u3002\u552F\u4E00\u4F8B\u5916\u662F user_actions \u7684 type=old_draft\uFF1A\u5E73\u53F0\u6709\u4E00\u4EFD\u7528\u6237\u4E0A\u6B21\u6CA1\u53D1\u5B8C\u7684\u5185\u5BB9\uFF0C\u8FD9\u7BC7\u5C1A\u672A\u63D0\u4EA4\u3002\u5FC5\u987B\u5148\u95EE\u7528\u6237\u662F\u5426\u653E\u5F03\u90A3\u4EFD\u65E7\u5185\u5BB9\uFF0C\u4E0D\u5F97\u66FF\u7528\u6237\u51B3\u5B9A\uFF1B\u7528\u6237\u660E\u786E\u540C\u610F\u540E\uFF0C\u624D\u7528\u65B0\u7684 request_id \u53EA\u5BF9\u8BE5\u5E73\u53F0\u518D\u8C03\u7528 publish\uFF0C\u5E76\u4F20 discard_old_drafts\uFF1B\u7528\u6237\u4E0D\u540C\u610F\u5C31\u4FDD\u7559\u65E7\u5185\u5BB9\uFF0C\u544A\u8BC9\u7528\u6237\u8FD9\u4E2A\u5E73\u53F0\u6CA1\u6709\u53D1\u3002";
50333
50388
  function createMcpServer(bridge) {
50334
50389
  const server = new McpServer({ name: "writedock", version: package_default.version }, {
50335
50390
  instructions: agentWorkflow + 'WriteDock \u901A\u8FC7\u672C\u673A\u6269\u5C55\u6267\u884C\u6295\u7A3F\u3002\u5148\u68C0\u67E5\u8FDE\u63A5\u548C\u5E73\u53F0\u767B\u5F55\u3002\u5B9E\u9645\u5E73\u53F0\u80FD\u529B\u4EE5 list_platforms \u8FD4\u56DE\u7684 action\u3001publishModes\u3001tools\u3001modeOptions\u3001confirmation\u3001capabilities\u3001coverRequired \u4E3A\u51C6\uFF0C\u6269\u5C55\u66F4\u65B0\u540E\u4F18\u5148\u6309\u8BE5\u5143\u4FE1\u606F\u9009\u62E9\u5DE5\u5177\u3002\u77E5\u4E4E\u3001\u767E\u5BB6\u53F7\u3001\u5FAE\u535A\u957F\u6587\u7AE0\u3001B\u7AD9\u4E13\u680F\u3001\u5C0F\u7EA2\u4E66\u957F\u6587\u4F7F\u7528 publish_article\uFF0CAgent \u76F4\u63A5\u63D0\u4F9B Markdown/HTML \u6216\u6587\u4EF6\u8DEF\u5F84\u5373\u53EF\u53D1\u5E03\uFF0C\u65E0\u9700\u7528\u6237\u5728\u6269\u5C55\u7F16\u8F91\u3002\u767E\u5BB6\u53F7\u5FC5\u987B\u63D0\u4F9B cover \u5C01\u9762\uFF1B\u77E5\u4E4E\u53E6\u652F\u6301 <video src="\u672C\u5730MP4\u8DEF\u5F84"></video> \u6587\u7AE0\u5185\u89C6\u9891\uFF0C\u72EC\u7ACB\u5C01\u9762\u6682\u672A\u63A5\u5165\u3002prepare_article \u4EC5\u4F9B\u53EF\u9009\u672C\u673A\u9884\u89C8\uFF0C\u4E0D\u662F\u53D1\u5E03\u7684\u524D\u7F6E\u8981\u6C42\u3002\u5C0F\u7EA2\u4E66 ID \u4E3A xiaohongshu\uFF0C\u4E09\u79CD\u6A21\u5F0F\u5171\u7528\u6B64\u5E73\u53F0\uFF1A\u5199\u957F\u6587\u7528 publish_article\uFF0C\u652F\u6301\u6B63\u6587\u56FE\u7247\u3001\u539F\u751F\u81EA\u52A8\u6392\u7248\u540E\u53D1\u5E03\uFF0C\u6807\u9898\u6700\u591A 64 \u5B57\u3001\u6B63\u6587\u6700\u591A 10000 \u5B57\uFF0C\u4E0D\u63A5\u72EC\u7ACB cover \u6216\u6587\u7AE0\u5185\u89C6\u9891\uFF1B\u666E\u901A\u56FE\u6587\u7528 publish_post(kind=image)\uFF0C\u72EC\u7ACB\u89C6\u9891\u7528 publish_post(kind=video)\uFF0C\u672A\u4F20 cover_path \u65F6\u4FDD\u7559\u5E73\u53F0\u81EA\u52A8\u751F\u6210\u7684\u7B2C\u4E00\u5E27\u5C01\u9762\u3002\u5C0F\u7EA2\u4E66\u6700\u7EC8\u5747\u987B\u5339\u914D POST /web_api/sns/v2/note \u7684 success=true\u3001result=0 \u548C\u7B14\u8BB0 ID \u56DE\u6267\u624D\u786E\u8BA4 submitted\u3002\u6296\u97F3\u5E73\u53F0 ID \u4E3A douyin\uFF0C\u56FE\u6587\u548C\u89C6\u9891\u540C\u6837\u7528 publish_post(kind=image|video)\uFF0C\u53EF\u9009 cover_path\uFF1B\u56FE\u6587\u8BDD\u9898\u6700\u591A 4 \u4E2A\u3001\u89C6\u9891\u6700\u591A 5 \u4E2A\uFF0C\u5F53\u524D\u53EA\u63A5\u5373\u65F6\u53D1\u5E03\u3002\u6296\u97F3 confirmation=native_page_message\uFF0C\u4EE5\u672C\u6B21\u64CD\u4F5C\u65B0\u589E\u7684\u539F\u751F\u6210\u529F\u63D0\u793A\u786E\u8BA4 submitted\uFF0C\u672A\u6838\u5BF9\u6700\u7EC8 HTTP \u4E1A\u52A1\u56DE\u6267\uFF1B\u4EC5\u8DF3\u8F6C\u4F5C\u54C1\u7BA1\u7406\u65F6\u4ECD\u4E3A unknown\uFF0CAgent \u4E0D\u81EA\u52A8\u91CD\u53D1\u3002\u5C0F\u7EA2\u4E66\u3001\u6296\u97F3\u3001\u5FAE\u535A\u666E\u901A\u56FE\u6587\u4E0E\u72EC\u7ACB\u89C6\u9891\u4F7F\u7528 prepare_post / publish_post\uFF0C\u5FAE\u535A\u5E73\u53F0 ID \u4ECD\u4E3A weibo\uFF0C\u4E0D\u53E6\u5EFA\u5E73\u53F0\u3002\u5FAE\u535A\u56FE\u6587\u6807\u9898\u653E\u5165\u6B63\u6587\u9996\u884C\uFF0C\u6700\u591A 18 \u5F20\u56FE\u7247\uFF1B\u72EC\u7ACB\u89C6\u9891\u6807\u9898\u6700\u591A 30 \u4E2A\u6C49\u5B57\uFF0860 \u4E2A\u534A\u89D2\u5B57\u7B26\uFF09\uFF0Cweibo_video_creation \u5FC5\u586B\uFF0C\u987B\u4F9D\u636E\u7528\u6237\u5B9E\u9645\u5185\u5BB9\u4F20 original\u3001secondary \u6216 repost\uFF0C\u4E0D\u80FD\u9ED8\u8BA4\u539F\u521B\u3002\u5FAE\u535A\u540C\u4E00\u6B21\u53EA\u80FD\u9009\u56FE\u6587\u6216\u89C6\u9891\uFF0C\u957F\u6587\u7AE0\u7EE7\u7EED\u7528 publish_article\u3002\u53EA\u6709\u7528\u6237\u660E\u786E\u8981\u6C42\u53D1\u5E03\u5E76\u6307\u5B9A\u5E73\u53F0\u65F6\u624D\u8C03\u7528\u53D1\u5E03\u5DE5\u5177\u3002\u5FAE\u4FE1\u516C\u4F17\u53F7\u3001\u641C\u72D0\u53F7\u76EE\u524D\u7528 sync_article \u4FDD\u5B58\u6587\u7AE0\u8349\u7A3F\u3002B\u7AD9\u4E13\u680F ID \u4E3A bilibili\uFF0C\u4F7F\u7528 publish_article \u76F4\u63A5\u53D1\u5E03\u6587\u5B57\u548C\u6B63\u6587\u56FE\u7247\uFF0C\u4E0D\u63A5\u89C6\u9891\u3001\u97F3\u9891\u3001\u9644\u52A0\u5185\u5BB9\u5361\u7247\u6216\u72EC\u7ACB\u5C01\u9762\uFF1B\u901A\u8FC7\u65B0\u7248\u539F\u751F\u7F16\u8F91\u5668\u4E0A\u4F20\u5E76\u53D1\u5E03\uFF0Ccode=-352 \u65F6\u505C\u6B62\u81EA\u52A8\u63D0\u4EA4\u5E76\u8F6C\u4EBA\u5DE5\u9A8C\u8BC1\u3002\u9047\u5230 needs_user_action \u6216 requires_user_action \u65F6\uFF0C\u628A user_actions \u4E2D\u7684\u539F\u56E0\u548C\u7A3F\u4EF6\u94FE\u63A5\u544A\u8BC9\u7528\u6237\uFF0C\u4F18\u5148\u8BA9\u7528\u6237\u5207\u6362\u5230\u6269\u5C55\u5DF2\u6253\u5F00\u7684\u9875\u9762\u5904\u7406\u9A8C\u8BC1\u7801\u3001\u5C01\u9762\u3001\u5185\u5BB9\u58F0\u660E\u3001\u6765\u6E90\u3001\u539F\u521B\u534F\u8BAE\u6216\u53D1\u5E03\u8BBE\u7F6E\uFF0C\u5E76\u624B\u52A8\u53D1\u5E03\uFF1B\u4E0D\u5F97\u81EA\u52A8\u89E3\u51B3\u9A8C\u8BC1\u7801\u3001\u91CD\u65B0\u63D0\u4EA4\u6216\u6362 request_id\u3002\u8BE5\u72B6\u6001\u53EA\u8BB0\u5F55\u4EBA\u5DE5\u63A5\u7BA1\uFF0C\u4E0D\u4EE3\u8868\u7528\u6237\u4E4B\u540E\u7684\u64CD\u4F5C\u7ED3\u679C\u5DF2\u88AB\u786E\u8BA4\u3002\u6269\u5C55\u66F4\u65B0\u590D\u7528\u5DF2\u6709 MCP\uFF1B\u672A\u8FDE\u63A5\u65F6\uFF0C\u8BA9\u7528\u6237\u6253\u5F00 WriteDock \u6269\u5C55\u7684\u300C\u8FDE\u63A5\u300D\u9875\u70B9\u201C\u8FDE\u63A5\u201D\u3002\u63D0\u4EA4\u8FD4\u56DE\u4EFB\u52A1 ID\uFF0C\u968F\u540E\u7528 get_sync_task \u67E5\u8BE2\u7ED3\u679C\u3002\u4FDD\u6301\u540C\u4E00\u64CD\u4F5C\u7684 request_id \u4E0D\u53D8\u3002publicationState=unknown \u6216\u4EFB\u52A1\u4E2D\u65AD\u65F6\uFF0C\u5411\u7528\u6237\u7ED9\u51FA\u94FE\u63A5\u8BA9\u5176\u81EA\u884C\u6838\u5BF9\u5E73\u53F0\uFF0CAgent \u4E0D\u6253\u5F00\u7F51\u9875\uFF0C\u4E0D\u8981\u81EA\u52A8\u751F\u6210\u65B0 request_id \u91CD\u53D1\u3002\u76F4\u63A5\u53D1\u5E03\u6210\u529F\u8868\u793A\u5E73\u53F0\u5DF2\u63A5\u53D7\u63D0\u4EA4\uFF0C\u662F\u5426\u516C\u5F00\u5C55\u793A\u4EE5\u5E73\u53F0\u4E3A\u51C6\u3002'
@@ -50339,7 +50394,7 @@ function createMcpServer(bridge) {
50339
50394
  })).catch((error2) => ({ content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }], isError: true }));
50340
50395
  const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
50341
50396
  server.registerTool("get_connection_status", {
50342
- description: "\u68C0\u67E5\u672C\u673A MCP \u4E0E\u6269\u5C55\u7684\u8FDE\u63A5\u3001\u8FD0\u884C\u7248\u672C\u548C npm \u66F4\u65B0\u63D0\u793A\u3002update_available=true \u65F6\u544A\u77E5\u7528\u6237\u91CD\u65B0\u542F\u52A8 WriteDock MCP\uFF0C\u4FDD\u7559\u914D\u7F6E\uFF1B\u53D1\u5E03\u4EFB\u52A1\u8FDB\u884C\u4E2D\u4E0D\u8981\u81EA\u52A8\u91CD\u542F\u3002\u65E7\u7248\u670D\u52A1\u53EF\u80FD\u4E0D\u8FD4\u56DE\u7248\u672C\u5B57\u6BB5\uFF1Blatest_version \u7F3A\u5931\u6216 update_checking=true \u65F6\uFF0C\u4E0D\u5F97\u58F0\u79F0\u5DF2\u662F\u6700\u65B0\u7248\u3002",
50397
+ description: "\u68C0\u67E5\u672C\u673A MCP \u4E0E\u6269\u5C55\u7684\u8FDE\u63A5\u3001\u8FD0\u884C\u7248\u672C\u548C npm \u66F4\u65B0\u63D0\u793A\u3002update_available=true \u65F6\u544A\u77E5\u7528\u6237\u91CD\u65B0\u542F\u52A8 WriteDock MCP\uFF0C\u4FDD\u7559\u914D\u7F6E\uFF1B\u53D1\u5E03\u4EFB\u52A1\u8FDB\u884C\u4E2D\u4E0D\u8981\u81EA\u52A8\u91CD\u542F\u3002\u6709 update_notice \u65F6\u8F6C\u544A\u7528\u6237\u3002extension_bundle_version \u53EA\u662F npm \u5305\u5185\u7F6E\u7684\u6269\u5C55\u7248\u672C\uFF0Cextension_version \u624D\u662F\u7528\u6237\u6B63\u5728\u7528\u7684\u3002\u65E7\u7248\u670D\u52A1\u53EF\u80FD\u4E0D\u8FD4\u56DE\u7248\u672C\u5B57\u6BB5\uFF1Blatest_version \u7F3A\u5931\u6216 update_checking=true \u65F6\uFF0C\u4E0D\u5F97\u58F0\u79F0\u5DF2\u662F\u6700\u65B0\u7248\u3002",
50343
50398
  inputSchema: {},
50344
50399
  annotations: { ...readOnly, openWorldHint: true }
50345
50400
  }, () => wrap(() => bridge.status()));
@@ -50615,12 +50670,88 @@ async function runSetup(home = (0, import_node_os3.homedir)(), log = console.log
50615
50670
  return found;
50616
50671
  }
50617
50672
 
50673
+ // src/service.ts
50674
+ var import_node_child_process2 = require("child_process");
50675
+ var import_node_fs2 = require("fs");
50676
+ var import_node_os4 = require("os");
50677
+ var import_node_path6 = require("path");
50678
+ var sleep = (ms) => new Promise((resolve4) => setTimeout(resolve4, ms));
50679
+ async function probe(port) {
50680
+ try {
50681
+ const response = await fetch(`http://127.0.0.1:${port}/hello`, { signal: AbortSignal.timeout(1500), cache: "no-store" });
50682
+ const value = await response.json();
50683
+ return value.service === "writedock-mcp" && typeof value.version === "string" ? { version: value.version } : "other";
50684
+ } catch (error2) {
50685
+ return error2 instanceof Error && error2.name === "TimeoutError" ? "other" : "none";
50686
+ }
50687
+ }
50688
+ async function until(check2, timeoutMs) {
50689
+ const end = Date.now() + timeoutMs;
50690
+ while (Date.now() < end) {
50691
+ if (await check2()) return true;
50692
+ await sleep(200);
50693
+ }
50694
+ return false;
50695
+ }
50696
+ function startDetached() {
50697
+ const directory = dataDirectory();
50698
+ (0, import_node_fs2.mkdirSync)(directory, { recursive: true, mode: 448 });
50699
+ const log = (0, import_node_fs2.openSync)((0, import_node_path6.join)(directory, "service.log"), "w");
50700
+ try {
50701
+ const child = (0, import_node_child_process2.spawn)(process.execPath, [__filename, "--service"], { detached: true, stdio: ["ignore", "ignore", log], windowsHide: true, cwd: (0, import_node_os4.homedir)() });
50702
+ child.on("error", () => {
50703
+ });
50704
+ child.unref();
50705
+ } finally {
50706
+ (0, import_node_fs2.closeSync)(log);
50707
+ }
50708
+ }
50709
+ async function ensureService(bridge, port) {
50710
+ let found = await probe(port);
50711
+ if (typeof found === "object" && newerVersion(package_default.version, found.version)) {
50712
+ if (!await bridge.askServiceToStop()) return;
50713
+ if (!await until(async () => await probe(port) === "none", 5e3)) return;
50714
+ found = "none";
50715
+ }
50716
+ if (found !== "none") return;
50717
+ startDetached();
50718
+ await until(async () => typeof await probe(port) === "object", 1e4);
50719
+ }
50720
+ function watchService(bridge, port) {
50721
+ let running = false;
50722
+ const timer = setInterval(() => {
50723
+ if (running) return;
50724
+ running = true;
50725
+ void ensureService(bridge, port).catch(() => {
50726
+ }).finally(() => {
50727
+ running = false;
50728
+ });
50729
+ }, 6e4);
50730
+ timer.unref();
50731
+ }
50732
+ var IDLE_CHECK_MS = 5e3;
50733
+ async function runService(bridge) {
50734
+ await bridge.start();
50735
+ if (!bridge.isPrimary) process.exit(0);
50736
+ const idleLimit = Number(process.env.WRITEDOCK_SERVICE_IDLE_MS) || 2 * 60 * 6e4;
50737
+ setInterval(() => {
50738
+ if (bridge.idleFor() >= idleLimit) void bridge.close().finally(() => process.exit(0));
50739
+ }, IDLE_CHECK_MS);
50740
+ const stop = () => {
50741
+ void bridge.close().finally(() => process.exit(0));
50742
+ };
50743
+ process.on("SIGINT", stop);
50744
+ process.on("SIGTERM", stop);
50745
+ }
50746
+
50618
50747
  // src/index.ts
50619
50748
  async function main() {
50620
50749
  const port = Number(process.env.WRITEDOCK_PORT || 9647);
50621
50750
  const identity = await loadIdentity(port, process.env.WRITEDOCK_TOKEN);
50622
- const bridge = new LocalBridge({ token: identity.token, port, credentials: identity });
50751
+ const background = !process.env.WRITEDOCK_NO_SERVICE;
50752
+ const bridge = new LocalBridge({ token: identity.token, port, credentials: identity, ...background ? { ensureService: () => ensureService(bridge, port) } : {} });
50623
50753
  await bridge.start();
50754
+ if (background) watchService(bridge, port);
50624
50755
  const server = createMcpServer(bridge);
50625
50756
  let closing = false;
50626
50757
  const close = async () => {
@@ -50643,10 +50774,31 @@ async function main() {
50643
50774
  };
50644
50775
  await server.connect(new StdioServerTransport());
50645
50776
  }
50777
+ async function service() {
50778
+ const port = Number(process.env.WRITEDOCK_PORT || 9647);
50779
+ const identity = await loadIdentity(port, process.env.WRITEDOCK_TOKEN);
50780
+ const bridge = new LocalBridge({ token: identity.token, port, credentials: identity, onStopRequested: () => {
50781
+ void bridge.close().finally(() => process.exit(0));
50782
+ } });
50783
+ await runService(bridge);
50784
+ }
50785
+ async function setup() {
50786
+ await runSetup();
50787
+ const port = Number(process.env.WRITEDOCK_PORT || 9647);
50788
+ const identity = await loadIdentity(port, process.env.WRITEDOCK_TOKEN);
50789
+ await ensureService(new LocalBridge({ token: identity.token, port, credentials: identity }), port).catch(() => {
50790
+ });
50791
+ process.exit(0);
50792
+ }
50646
50793
  if (process.argv.slice(2).some((argument) => argument === "--version" || argument === "-v")) {
50647
50794
  console.log(package_default.version);
50648
50795
  } else if (process.argv[2] === "setup") {
50649
- runSetup().catch((error2) => {
50796
+ setup().catch((error2) => {
50797
+ console.error("[WriteDock MCP]", error2 instanceof Error ? error2.message : String(error2));
50798
+ process.exit(1);
50799
+ });
50800
+ } else if (process.argv[2] === "--service") {
50801
+ service().catch((error2) => {
50650
50802
  console.error("[WriteDock MCP]", error2 instanceof Error ? error2.message : String(error2));
50651
50803
  process.exit(1);
50652
50804
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "writedock-mcp",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
4
4
  "description": "WriteDock local MCP server for the browser extension",
5
5
  "type": "commonjs",
6
6
  "license": "GPL-3.0-only",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writedock/mcp-server",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "WriteDock local MCP server for the browser extension",
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { updateNotice } from '../release-info'
3
+
4
+ describe('updateNotice', () => {
5
+ it('offers a newer extension and says what to click', () => {
6
+ const text = updateNotice({ bundle: '0.4.13', extension: '0.4.12' })
7
+ expect(text).toContain('v0.4.13')
8
+ expect(text).toContain('拼图图标')
9
+ expect(text).toContain('连接 agent')
10
+ expect(text).toContain('更新扩展')
11
+ expect(text).toContain('不要因此重发')
12
+ })
13
+ it('treats an extension that reports no version as older', () => {
14
+ expect(updateNotice({ bundle: '0.4.13' })).toContain('v0.4.13')
15
+ })
16
+ it('compares versions as numbers', () => {
17
+ expect(updateNotice({ bundle: '0.4.10', extension: '0.4.9' })).toContain('v0.4.10')
18
+ expect(updateNotice({ bundle: '0.4.9', extension: '0.4.10' })).toBeUndefined()
19
+ })
20
+ it('stays quiet when everything is current or nothing can be offered', () => {
21
+ expect(updateNotice({ bundle: '0.4.13', extension: '0.4.13', mcp: '0.4.13', mcpLatest: '0.4.13' })).toBeUndefined()
22
+ expect(updateNotice({ extension: '0.4.1' })).toBeUndefined()
23
+ })
24
+ it('offers a newer service with a restart', () => {
25
+ expect(updateNotice({ mcp: '0.4.12', mcpLatest: '0.4.13' })).toContain('重启 agent')
26
+ })
27
+ })
@@ -5,7 +5,7 @@ import http from 'node:http'
5
5
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
6
6
  import { WebSocket, WebSocketServer } from 'ws'
7
7
  import { MediaStore, type MediaAsset } from './media'
8
- import { mcpReleaseInfo } from './release-info'
8
+ import { mcpReleaseInfo, updateNotice } from './release-info'
9
9
  import { EXTENSION_ORIGIN } from './extension-id'
10
10
  import { updateInstalledExtension } from './extension-update'
11
11
 
@@ -27,12 +27,15 @@ export class LocalBridge {
27
27
  private starting?: Promise<void>
28
28
  private primary = false
29
29
  private extensionId?: string
30
+ private extensionVersion?: string
30
31
  private seenNonces = new Map<string, number>()
31
32
  private extensionToken: string
33
+ private lastWork = 0
34
+ private lastSeen = Date.now()
32
35
  private media: MediaStore
33
36
  private pending = new Map<string, { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }>()
34
37
  readonly port: number
35
- constructor(private options: { token: string; port?: number; timeoutMs?: number; credentials?: { getExtensionToken: () => Promise<string>; setExtensionToken: (token: string) => Promise<void> } }) {
38
+ constructor(private options: { token: string; port?: number; timeoutMs?: number; credentials?: { getExtensionToken: () => Promise<string>; setExtensionToken: (token: string) => Promise<void> }; ensureService?: () => Promise<void>; onStopRequested?: () => void }) {
36
39
  this.port = options.port ?? 9647
37
40
  this.extensionToken = options.token
38
41
  this.media = new MediaStore(this.port)
@@ -42,7 +45,7 @@ export class LocalBridge {
42
45
 
43
46
  async start(): Promise<void> {
44
47
  if (this.starting) return this.starting
45
- this.starting = this.listen().catch(async error => {
48
+ this.starting = Promise.resolve(this.options.ensureService?.()).catch(() => {}).then(() => this.listen()).catch(async error => {
46
49
  if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw error
47
50
  this.server = undefined
48
51
  this.sockets = undefined
@@ -78,6 +81,31 @@ export class LocalBridge {
78
81
  this.heartbeat.unref()
79
82
  }
80
83
 
84
+ get isPrimary() { return this.primary }
85
+
86
+ private notice() {
87
+ if (this.client?.readyState !== WebSocket.OPEN) return undefined
88
+ const release = mcpReleaseInfo()
89
+ return updateNotice({ bundle: release.extension_bundle_version, extension: this.extensionVersion, mcpLatest: release.latest_version, mcp: release.mcp_version })
90
+ }
91
+
92
+ private connectionFields() {
93
+ const connected = this.client?.readyState === WebSocket.OPEN, notice = this.notice()
94
+ return { connected, extensionId: this.extensionId, ...(this.extensionVersion ? { extension_version: this.extensionVersion } : {}), ...(notice ? { update_notice: notice } : {}), protocol: 'writedock/1', ...mcpReleaseInfo() }
95
+ }
96
+
97
+ /** How long nothing has needed this service. An open extension connection counts as needed. */
98
+ idleFor() {
99
+ if (this.client?.readyState === WebSocket.OPEN) this.lastSeen = Date.now()
100
+ return Date.now() - this.lastSeen
101
+ }
102
+
103
+ /** Asks the running service to step aside for a newer one. It says no while it has work. */
104
+ async askServiceToStop() {
105
+ try { return (await this.httpCall('/stop', {})).accepted === true }
106
+ catch { return false } // Versions before this one have no such route, and leave with their agent.
107
+ }
108
+
81
109
  private validHost(host?: string) { return host === `127.0.0.1:${this.port}` || host === `localhost:${this.port}` }
82
110
 
83
111
  /** Replaces the extension credential and drops whatever was using the old one. */
@@ -86,7 +114,7 @@ export class LocalBridge {
86
114
  await this.options.credentials?.setExtensionToken(token)
87
115
  this.extensionToken = token
88
116
  const previousClient = this.client
89
- this.client = undefined; this.extensionId = undefined
117
+ this.client = undefined; this.extensionId = undefined; this.extensionVersion = undefined
90
118
  for (const value of this.pending.values()) { clearTimeout(value.timer); value.reject(new Error(reason)) }
91
119
  this.pending.clear()
92
120
  previousClient?.close(1000, 'Extension connection replaced')
@@ -117,7 +145,7 @@ export class LocalBridge {
117
145
  }
118
146
  if (this.client?.readyState === WebSocket.OPEN) { ws.close(1008, 'An extension is already connected'); return }
119
147
  authenticated = true; clearTimeout(timer)
120
- this.client = ws; this.extensionId = String(message.extensionId || '')
148
+ this.client = ws; this.extensionId = String(message.extensionId || ''); this.extensionVersion = typeof message.extensionVersion === 'string' && message.extensionVersion.length <= 40 ? message.extensionVersion : undefined
121
149
  ws.send(JSON.stringify({ type: 'ready', ...mcpReleaseInfo() }))
122
150
  return
123
151
  }
@@ -139,7 +167,7 @@ export class LocalBridge {
139
167
  ws.on('close', () => {
140
168
  clearTimeout(timer)
141
169
  if (this.client !== ws) return
142
- this.client = undefined; this.extensionId = undefined
170
+ this.client = undefined; this.extensionId = undefined; this.extensionVersion = undefined
143
171
  for (const value of this.pending.values()) {
144
172
  clearTimeout(value.timer)
145
173
  value.reject(new Error('扩展连接已断开。若已提交同步,请按任务 ID 查询结果,不要更换 request_id 自动重发。'))
@@ -165,6 +193,7 @@ export class LocalBridge {
165
193
  }
166
194
  const assetPath = /^\/assets\/([a-f0-9-]{36})$/.exec(req.url || '')
167
195
  if (assetPath && this.validHost(req.headers.host) && (!req.headers.origin || /^chrome-extension:\/\/[a-p]{32}$/.test(req.headers.origin))) {
196
+ this.lastWork = this.lastSeen = Date.now()
168
197
  await this.media.serve(req, res, assetPath[1]); return
169
198
  }
170
199
  if (!this.validHost(req.headers.host) || req.headers.origin !== undefined) {
@@ -188,9 +217,17 @@ export class LocalBridge {
188
217
  for (const [key, expires] of this.seenNonces) if (expires < now) this.seenNonces.delete(key)
189
218
  this.seenNonces.set(nonce, now + 30_000)
190
219
  responseNonce = nonce
220
+ if (req.method === 'POST' && req.url === '/stop') {
221
+ const accepted = this.pending.size === 0 && Date.now() - this.lastWork > 120_000
222
+ send(200, { accepted })
223
+ if (accepted) setImmediate(() => this.options.onStopRequested?.())
224
+ return
225
+ }
226
+ this.lastSeen = Date.now() // Status checks keep the service around, but only real work stops it being replaced.
191
227
  if (req.method === 'GET' && req.url === '/status') {
192
- send(200, { connected: this.client?.readyState === WebSocket.OPEN, extensionId: this.extensionId, protocol: 'writedock/1', ...mcpReleaseInfo() }); return
228
+ send(200, this.connectionFields()); return
193
229
  }
230
+ this.lastWork = Date.now()
194
231
  if (req.method === 'POST' && req.url === '/assets/register') {
195
232
  const input = JSON.parse(body)
196
233
  if (typeof input.path !== 'string' || !['image', 'video'].includes(input.kind)) throw new Error('素材参数不正确')
@@ -223,7 +260,7 @@ export class LocalBridge {
223
260
 
224
261
  async status() {
225
262
  await this.start()
226
- if (this.primary) return { connected: this.client?.readyState === WebSocket.OPEN, extensionId: this.extensionId, protocol: 'writedock/1', ...mcpReleaseInfo() }
263
+ if (this.primary) return this.connectionFields()
227
264
  try { return await this.httpCall('/status') }
228
265
  catch (error) {
229
266
  if (!(error instanceof TypeError)) throw error
@@ -231,7 +268,7 @@ export class LocalBridge {
231
268
  this.starting = undefined
232
269
  await this.start()
233
270
  if (this.primary) return { connected: false, protocol: 'writedock/1', ...mcpReleaseInfo() }
234
- throw error
271
+ return this.httpCall('/status') // A background service took the port in the meantime.
235
272
  }
236
273
  }
237
274
 
@@ -248,14 +285,14 @@ export class LocalBridge {
248
285
 
249
286
  async registerMedia(path: string, kind: 'image' | 'video'): Promise<MediaAsset> {
250
287
  await this.status()
251
- if (this.primary) return this.media.register(path, kind)
288
+ if (this.primary) { this.lastWork = this.lastSeen = Date.now(); return this.media.register(path, kind) }
252
289
  return await this.httpCall('/assets/register', { path, kind }) as unknown as MediaAsset
253
290
  }
254
291
 
255
292
  private async requestConnected(method: string, params: Record<string, unknown>): Promise<unknown> {
256
293
  if (this.client?.readyState !== WebSocket.OPEN) throw new Error('扩展未连接,这次没有发布。请让用户打开 Chrome 或 Edge,连接过的扩展通常会自动连上,然后再试;如果还是不行,打开 WriteDock 扩展点“连接”。无需重装 MCP。')
257
294
  const id = randomUUID()
258
- return new Promise((resolve, reject) => {
295
+ return new Promise<unknown>((resolve, reject) => {
259
296
  const timer = setTimeout(() => {
260
297
  this.pending.delete(id)
261
298
  reject(new Error('扩展响应超时;同步任务可能仍在运行,请查询任务状态。'))
@@ -265,6 +302,10 @@ export class LocalBridge {
265
302
  if (!error) return
266
303
  clearTimeout(timer); this.pending.delete(id); reject(error)
267
304
  })
305
+ }).then(result => {
306
+ // Only on a finished task, so the agent says it once at the end and never in the middle of a publication.
307
+ const notice = result && typeof result === 'object' && (result as { finished?: unknown }).finished === true ? this.notice() : undefined
308
+ return notice ? { ...result as object, update_notice: notice } : result
268
309
  })
269
310
  }
270
311
 
@@ -5,12 +5,16 @@ import { createMcpServer } from './server'
5
5
  import { loadIdentity } from './identity'
6
6
  import mcpPackage from '../package.json'
7
7
  import { runSetup } from './setup'
8
+ import { ensureService, runService, watchService } from './service'
8
9
 
9
10
  async function main() {
10
11
  const port = Number(process.env.WRITEDOCK_PORT || 9647)
11
12
  const identity = await loadIdentity(port, process.env.WRITEDOCK_TOKEN)
12
- const bridge = new LocalBridge({ token: identity.token, port, credentials: identity })
13
+ // Set only for tests that need one process owning the port by itself.
14
+ const background = !process.env.WRITEDOCK_NO_SERVICE
15
+ const bridge: LocalBridge = new LocalBridge({ token: identity.token, port, credentials: identity, ...(background ? { ensureService: () => ensureService(bridge, port) } : {}) })
13
16
  await bridge.start()
17
+ if (background) watchService(bridge, port)
14
18
  const server = createMcpServer(bridge)
15
19
  let closing = false
16
20
  const close = async () => { if (closing) return; closing = true; await bridge.close(); await server.close() }
@@ -20,11 +24,29 @@ async function main() {
20
24
  server.server.onclose = () => { void bridge.close() }
21
25
  await server.connect(new StdioServerTransport())
22
26
  }
27
+ async function service() {
28
+ const port = Number(process.env.WRITEDOCK_PORT || 9647)
29
+ const identity = await loadIdentity(port, process.env.WRITEDOCK_TOKEN)
30
+ const bridge: LocalBridge = new LocalBridge({ token: identity.token, port, credentials: identity, onStopRequested: () => { void bridge.close().finally(() => process.exit(0)) } })
31
+ await runService(bridge)
32
+ }
33
+
34
+ /** Setup starts the service too, so 连接 appears even with an agent that only runs tools when it needs them. */
35
+ async function setup() {
36
+ await runSetup()
37
+ const port = Number(process.env.WRITEDOCK_PORT || 9647)
38
+ const identity = await loadIdentity(port, process.env.WRITEDOCK_TOKEN)
39
+ await ensureService(new LocalBridge({ token: identity.token, port, credentials: identity }), port).catch(() => {})
40
+ process.exit(0)
41
+ }
42
+
23
43
  if (process.argv.slice(2).some(argument => argument === '--version' || argument === '-v')) {
24
44
  // Allow inspecting an npm archive without starting a bridge or reading pairing credentials.
25
45
  console.log(mcpPackage.version)
26
46
  } else if (process.argv[2] === 'setup') {
27
- runSetup().catch(error => { console.error('[WriteDock MCP]', error instanceof Error ? error.message : String(error)); process.exit(1) })
47
+ setup().catch(error => { console.error('[WriteDock MCP]', error instanceof Error ? error.message : String(error)); process.exit(1) })
48
+ } else if (process.argv[2] === '--service') {
49
+ service().catch(error => { console.error('[WriteDock MCP]', error instanceof Error ? error.message : String(error)); process.exit(1) })
28
50
  } else {
29
51
  main().catch(error => { console.error('[WriteDock MCP]', error instanceof Error ? error.message : String(error)); process.exit(1) })
30
52
  }
@@ -68,3 +68,16 @@ export function mcpReleaseInfo() {
68
68
  update_checking: !!pending,
69
69
  }
70
70
  }
71
+
72
+ /**
73
+ * What the agent should pass on to the user once a task has finished. An extension that reports no version
74
+ * predates reporting it, so it is older than any bundle that can compare. Without a bundle nothing can be offered.
75
+ */
76
+ export function updateNotice(input: { bundle?: string; extension?: string; mcpLatest?: string; mcp?: string }) {
77
+ const notices: string[] = []
78
+ if (input.bundle && (!input.extension || newerVersion(input.bundle, input.extension))) {
79
+ notices.push(`WriteDock 扩展有新版本 v${input.bundle}${input.extension ? `(当前 v${input.extension})` : ''},需要用户自己点一下才会更新:点浏览器右上角的拼图图标,点 WriteDock,进入“连接 agent”,点“更新扩展”。连接和稿件都会保留。`)
80
+ }
81
+ if (input.mcpLatest && input.mcp && newerVersion(input.mcpLatest, input.mcp)) notices.push(`WriteDock 服务有新版本 v${input.mcpLatest}:重启 agent 就会更新。`)
82
+ return notices.length ? `${notices.join('')}请在汇报结果时把这些步骤原样告诉用户一次。这不影响本次结果,不要等用户更新,也不要因此重发。` : undefined
83
+ }