writedock-mcp 0.0.0-stage → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "writedock-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.4.2",
4
+ "description": "WriteDock local MCP server for the browser extension",
5
+ "type": "commonjs",
6
+ "license": "GPL-3.0-only",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "bin": {
11
+ "writedock-mcp": "index.cjs"
12
+ },
13
+ "files": [
14
+ "index.cjs",
15
+ "README.md",
16
+ "LICENSE",
17
+ "LICENSE-MULTIPOST",
18
+ "NOTICE.md",
19
+ "THIRD_PARTY_NOTICES.txt",
20
+ "source"
21
+ ],
22
+ "keywords": [
23
+ "mcp",
24
+ "writedock",
25
+ "browser-extension",
26
+ "publishing"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "registry": "https://registry.npmjs.org/",
31
+ "tag": "latest"
32
+ }
6
33
  }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "writedock-mcp",
3
+ "tag": "latest",
4
+ "registry": "https://registry.npmjs.org/"
5
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@writedock/mcp-server",
3
+ "version": "0.4.2",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "WriteDock local MCP server for the browser extension",
7
+ "license": "GPL-3.0-only",
8
+ "bin": {
9
+ "writedock-mcp": "dist/index.cjs"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc --noEmit && tsup",
13
+ "typecheck": "tsc --noEmit",
14
+ "test": "vitest run"
15
+ },
16
+ "dependencies": {
17
+ "@modelcontextprotocol/sdk": "^1.25.2",
18
+ "ws": "^8.18.0",
19
+ "zod": "^3.25.76",
20
+ "marked": "^17.0.1",
21
+ "linkedom": "^0.18.12",
22
+ "yaml": "^2.3.4"
23
+ },
24
+ "devDependencies": {
25
+ "@types/ws": "^8.5.13",
26
+ "tsup": "^8.0.1",
27
+ "vitest": "^2.1.9",
28
+ "@types/node": "^22.10.2"
29
+ }
30
+ }
@@ -0,0 +1,49 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { resolve } from 'node:path'
5
+ import { prepareSource } from '../article'
6
+
7
+ const roots: string[] = []
8
+ const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg==', 'base64')
9
+ afterEach(async () => { await Promise.all(roots.splice(0).map(path => rm(path, { force: true, recursive: true }))) })
10
+
11
+ async function fixture() {
12
+ const path = await mkdtemp(resolve(tmpdir(), 'writedock-article-'))
13
+ roots.push(path)
14
+ await writeFile(resolve(path, 'image.png'), png)
15
+ return path
16
+ }
17
+
18
+ describe('agent-authored article input', () => {
19
+ it('reads Markdown title and reference images, embeds local pictures and the cover', async () => {
20
+ const path = await fixture()
21
+ await writeFile(resolve(path, 'article.md'), '# 同步示例\n\n正文 **加粗**。\n\n![示意图][picture]\n\n[picture]: ./image.png\n')
22
+ const result = await prepareSource({ file_path: resolve(path, 'article.md'), cover: './image.png' })
23
+ expect(result.title).toBe('同步示例')
24
+ expect(result.html).toContain('<strong>加粗</strong>')
25
+ expect(result.html).toContain('data:image/png;base64,')
26
+ expect(result.html).not.toContain('./image.png')
27
+ expect(result.cover).toMatch(/^data:image\/png;base64,/)
28
+ })
29
+
30
+ it('supports HTML files and explicit titles without reading ordinary links as files', async () => {
31
+ const path = await fixture()
32
+ const result = await prepareSource({ html: '<p>正文 <a href="/etc/secret">链接</a></p><img src="image.png">', title: 'HTML 稿件', base_directory: path })
33
+ expect(result.title).toBe('HTML 稿件')
34
+ expect(result.html).toContain('/etc/secret')
35
+ expect(result.html).toContain('data:image/png;base64,')
36
+ await expect(prepareSource({ markdown: '正文' })).rejects.toThrow('标题')
37
+ })
38
+
39
+ it('rejects directory traversal, invalid image bytes, video and ambiguous inputs', async () => {
40
+ const path = await fixture()
41
+ await mkdir(resolve(path, 'nested'))
42
+ await expect(prepareSource({ title: '测试', markdown: '![x](../image.png)', base_directory: resolve(path, 'nested') })).rejects.toThrow('必须位于')
43
+ await writeFile(resolve(path, 'invalid.png'), 'this is not an image')
44
+ await expect(prepareSource({ title: '测试', markdown: '![x](invalid.png)', base_directory: path })).rejects.toThrow('有效的 PNG')
45
+ await writeFile(resolve(path, 'video.mp4'), 'video')
46
+ await expect(prepareSource({ file_path: resolve(path, 'video.mp4') })).rejects.toThrow('稿件文件仅支持')
47
+ await expect(prepareSource({ title: '测试', markdown: '正文', html: '<p>正文</p>' })).rejects.toThrow('一种稿件来源')
48
+ })
49
+ })
@@ -0,0 +1,90 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { createServer } from 'node:net'
3
+ import { once } from 'node:events'
4
+ import { randomBytes } from 'node:crypto'
5
+ import { WebSocket } from 'ws'
6
+ import { LocalBridge, proof, requestProof } from '../bridge'
7
+
8
+ const token = 'test-pairing-key-that-is-long-enough'
9
+ const brokers: LocalBridge[] = []
10
+ const clients: WebSocket[] = []
11
+ async function unusedPort() {
12
+ const listener = createServer().listen(0, '127.0.0.1')
13
+ await once(listener, 'listening')
14
+ const port = (listener.address() as { port: number }).port
15
+ await new Promise<void>(resolve => listener.close(() => resolve()))
16
+ return port
17
+ }
18
+ async function broker() {
19
+ const bridge = new LocalBridge({ token, port: await unusedPort(), timeoutMs: 1000 })
20
+ brokers.push(bridge); await bridge.start(); return bridge
21
+ }
22
+ async function pair(bridge: LocalBridge, key = token) {
23
+ const ws = new WebSocket(`ws://127.0.0.1:${bridge.port}/extension`)
24
+ clients.push(ws)
25
+ ws.on('message', raw => {
26
+ const message = JSON.parse(raw.toString())
27
+ if (message.type === 'challenge') ws.send(JSON.stringify({ type: 'hello', proof: proof(key, 'extension', message.nonce), extensionId: 'fixture-extension' }))
28
+ })
29
+ return ws
30
+ }
31
+ afterEach(async () => { clients.splice(0).forEach(ws => ws.terminate()); await Promise.all(brokers.splice(0).map(value => value.close())) })
32
+
33
+ describe('authenticated local bridge', () => {
34
+ it('rejects unauthenticated HTTP calls and browser-origin requests', async () => {
35
+ const bridge = await broker()
36
+ const url = `http://127.0.0.1:${bridge.port}/status`
37
+ const nonce = randomBytes(32).toString('hex'), time = String(Date.now())
38
+ const headers = { 'X-Writedock-Nonce': nonce, 'X-Writedock-Time': time, 'X-Writedock-Proof': requestProof(token, 'GET', '/status', nonce, time) }
39
+ expect((await fetch(url)).status).toBe(403)
40
+ expect((await fetch(url, { headers: { ...headers, Origin: 'https://example.com' } })).status).toBe(403)
41
+ expect((await fetch(url, { headers })).status).toBe(200)
42
+ expect((await fetch(url, { headers })).status).toBe(403)
43
+ })
44
+
45
+ it('rejects an extension with the wrong key, then connects an authenticated extension', async () => {
46
+ const bridge = await broker()
47
+ const wrong = await pair(bridge, 'wrong-key')
48
+ const closed = await once(wrong, 'close')
49
+ expect(closed[0]).toBe(1008)
50
+ const ws = await pair(bridge)
51
+ await new Promise<void>(resolve => ws.on('message', raw => { if (JSON.parse(raw.toString()).type === 'ready') resolve() }))
52
+ expect((await bridge.status()).connected).toBe(true)
53
+ ws.on('message', raw => {
54
+ const message = JSON.parse(raw.toString())
55
+ if (message.method) ws.send(JSON.stringify({ id: message.id, result: { method: message.method } }))
56
+ })
57
+ expect(await bridge.request('getSyncHistory')).toEqual({ method: 'getSyncHistory' })
58
+ })
59
+
60
+ it('allows two MCP clients to share a port without replacing the extension or re-sending requests', async () => {
61
+ const first = await broker()
62
+ const ws = await pair(first)
63
+ await new Promise<void>(resolve => ws.on('message', raw => { if (JSON.parse(raw.toString()).type === 'ready') resolve() }))
64
+ let requests = 0
65
+ ws.on('message', raw => {
66
+ const message = JSON.parse(raw.toString())
67
+ if (message.method) { requests++; ws.send(JSON.stringify({ id: message.id, result: 'ok' })) }
68
+ })
69
+ const second = new LocalBridge({ token, port: first.port, timeoutMs: 1000 })
70
+ brokers.push(second)
71
+ await second.start()
72
+ expect(await second.request('listPlatforms')).toBe('ok')
73
+ expect(requests).toBe(1)
74
+ const wrong = new LocalBridge({ token: 'other-valid-key-that-is-long-enough', port: first.port })
75
+ brokers.push(wrong)
76
+ await expect(wrong.start()).rejects.toThrow('认证失败')
77
+ })
78
+
79
+ it('lets a remaining MCP client acquire the bridge when the first process exits', async () => {
80
+ const first = await broker()
81
+ const second = new LocalBridge({ token, port: first.port, timeoutMs: 1000 })
82
+ brokers.push(second)
83
+ await second.start()
84
+ await first.close()
85
+ expect((await second.status()).connected).toBe(false)
86
+ const ws = await pair(second)
87
+ await new Promise<void>(resolve => ws.on('message', raw => { if (JSON.parse(raw.toString()).type === 'ready') resolve() }))
88
+ expect((await second.status()).connected).toBe(true)
89
+ })
90
+ })
@@ -0,0 +1,127 @@
1
+ import { readFile, realpath, stat } from 'node:fs/promises'
2
+ import { dirname, isAbsolute, relative, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { marked, type Tokens } from 'marked'
5
+ import { parseHTML } from 'linkedom'
6
+ import { parse as parseYaml } from 'yaml'
7
+ import { mediaPath, type MediaAsset } from './media'
8
+
9
+ export interface ArticleSource {
10
+ file_path?: string
11
+ title?: string
12
+ markdown?: string
13
+ html?: string
14
+ cover?: string
15
+ base_directory?: string
16
+ }
17
+ const ARTICLE_LIMIT = 24 * 1024 * 1024
18
+ const IMAGE_LIMIT = 8 * 1024 * 1024
19
+ const TEXT_LIMIT = 2 * 1024 * 1024
20
+
21
+ function imageMime(bytes: Buffer): string {
22
+ if (bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) return 'image/png'
23
+ if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return 'image/jpeg'
24
+ if (/^GIF8[79]a$/.test(bytes.subarray(0, 6).toString())) return 'image/gif'
25
+ if (bytes.subarray(0, 4).toString() === 'RIFF' && bytes.subarray(8, 12).toString() === 'WEBP') return 'image/webp'
26
+ throw new Error('文章插图只支持有效的 PNG、JPEG、GIF、WebP;视频投稿请使用 prepare_post / publish_post')
27
+ }
28
+
29
+ export async function prepareSource(source: ArticleSource, registerVideo?: (path: string) => Promise<MediaAsset>) {
30
+ const choices = [source.file_path, source.markdown, source.html].filter(value => value !== undefined)
31
+ if (choices.length !== 1) throw new Error('请只提供 file_path、markdown 或 html 中的一种稿件来源')
32
+ let markdown = source.markdown
33
+ let html = source.html
34
+ let title = source.title?.trim() || ''
35
+ let baseDirectory = source.base_directory ? await realpath(source.base_directory) : undefined
36
+ if (source.file_path) {
37
+ if (!isAbsolute(source.file_path)) throw new Error('file_path 必须是绝对路径')
38
+ const filename = await realpath(source.file_path)
39
+ const info = await stat(filename)
40
+ if (!info.isFile() || info.size > TEXT_LIMIT) throw new Error('稿件必须是 2 MB 以内的 Markdown 或 HTML 文件')
41
+ const text = (await readFile(filename, 'utf8')).replace(/^\uFEFF/, '')
42
+ if (/\.(md|markdown|mdown)$/i.test(filename)) markdown = text
43
+ else if (/\.html?$/i.test(filename)) html = text
44
+ else throw new Error('稿件文件仅支持 .md、.markdown、.html、.htm')
45
+ baseDirectory ??= dirname(filename)
46
+ }
47
+
48
+ const embedded = new Map<string, string>()
49
+ let imageBytes = 0
50
+ const embedImage = async (src: string): Promise<string> => {
51
+ if (embedded.has(src)) return embedded.get(src)!
52
+ if (/^https?:\/\//i.test(src)) { new URL(src); return src }
53
+ let bytes: Buffer
54
+ if (src.startsWith('data:')) {
55
+ const match = /^data:image\/(?:png|jpeg|gif|webp);base64,([A-Za-z0-9+/=\r\n]+)$/i.exec(src)
56
+ if (!match) throw new Error('图片 data URI 无效,或图片格式不受支持')
57
+ bytes = Buffer.from(match[1], 'base64')
58
+ } else {
59
+ if (!baseDirectory) throw new Error('稿件包含本地图片,请提供 base_directory 或使用 file_path')
60
+ const path = src.startsWith('file:') ? fileURLToPath(src) : decodeURIComponent(src)
61
+ if (/^[a-z][a-z0-9+.-]*:/i.test(path) && !isAbsolute(path)) throw new Error('图片地址只支持 HTTP(S)、本地图片或 data URI')
62
+ const filename = await realpath(resolve(baseDirectory, path))
63
+ const inside = relative(baseDirectory, filename)
64
+ if (inside === '..' || inside.startsWith('../') || inside.startsWith('..\\') || isAbsolute(inside)) throw new Error('本地图片必须位于稿件目录或明确指定的 base_directory 内')
65
+ const info = await stat(filename)
66
+ if (!info.isFile() || info.size > IMAGE_LIMIT) throw new Error('单张本地图片不能超过 8 MB')
67
+ bytes = await readFile(filename)
68
+ }
69
+ if (!bytes.length || bytes.length > IMAGE_LIMIT) throw new Error('单张图片必须在 8 MB 以内')
70
+ const mime = imageMime(bytes)
71
+ imageBytes += bytes.length
72
+ if (imageBytes > 18 * 1024 * 1024) throw new Error('稿件图片总量过大,请先压缩图片')
73
+ const result = `data:${mime};base64,${bytes.toString('base64')}`
74
+ embedded.set(src, result)
75
+ embedded.set(result, result)
76
+ return result
77
+ }
78
+
79
+ if (markdown !== undefined) {
80
+ const front = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(markdown)
81
+ if (front) {
82
+ const meta: unknown = parseYaml(front[1])
83
+ if (!title && meta && typeof meta === 'object' && 'title' in meta && typeof meta.title === 'string') title = meta.title.trim()
84
+ markdown = markdown.slice(front[0].length)
85
+ }
86
+ const heading = /^\s*#\s+(.+?)(?:\r?\n|$)/.exec(markdown)
87
+ if (heading) {
88
+ title ||= heading[1].trim()
89
+ if (heading[1].trim() === title) markdown = markdown.slice(heading[0].length)
90
+ }
91
+ const tokens = marked.lexer(markdown)
92
+ const images: Tokens.Image[] = []
93
+ marked.walkTokens(tokens, token => { if (token.type === 'image') images.push(token as Tokens.Image) })
94
+ for (const image of images) image.href = await embedImage(image.href)
95
+ html = marked.parser(tokens)
96
+ }
97
+ const { document } = parseHTML(/<html\b/i.test(html || '') ? html! : `<html><head></head><body>${html || ''}</body></html>`)
98
+ title ||= document.querySelector('title')?.textContent?.trim() || document.querySelector('h1')?.textContent?.trim() || ''
99
+ const videos: { marker: string; asset: MediaAsset }[] = []
100
+ for (const video of document.querySelectorAll('video')) {
101
+ if (!registerVideo) throw new Error('文章内嵌视频请使用 prepare_article / publish_article,目前只支持知乎')
102
+ if (videos.length >= 5) throw new Error('当前每篇知乎文章最多接收 5 个本地视频')
103
+ const src = video.getAttribute('src') || video.querySelector('source')?.getAttribute('src')
104
+ if (!src || /^(https?:|data:|blob:)/i.test(src)) throw new Error('内嵌视频需要通过 <video src="本地路径"></video> 提供本机 MP4 文件')
105
+ const path = src.startsWith('file:') ? fileURLToPath(src) : decodeURIComponent(src)
106
+ const asset = await registerVideo(await mediaPath(path, baseDirectory))
107
+ if (asset.type !== 'video/mp4') throw new Error('当前知乎文章内嵌视频仅接入 MP4 格式')
108
+ if (videos.reduce((sum, item) => sum + item.asset.size, asset.size) > 512 * 1024 * 1024) throw new Error('文章内嵌视频总量不能超过 512 MB')
109
+ const marker = `WRITEDOCK_VIDEO_${videos.length + 1}`
110
+ if (document.body.textContent?.includes(marker)) throw new Error('正文含有保留的视频占位符,请修改后重新提交')
111
+ const placeholder = document.createElement('p')
112
+ placeholder.textContent = marker
113
+ video.replaceWith(placeholder)
114
+ videos.push({ marker, asset })
115
+ }
116
+ for (const image of document.querySelectorAll('img')) {
117
+ const src = image.getAttribute('src')
118
+ if (!src) throw new Error('正文包含没有地址的图片')
119
+ image.setAttribute('src', await embedImage(src))
120
+ image.removeAttribute('srcset')
121
+ }
122
+ const cover = source.cover ? await embedImage(source.cover) : undefined
123
+ if (!title || title.length > 200) throw new Error('请提供 1–200 字的文章标题,或在稿件中使用一级标题')
124
+ const article = { title, html: document.body.innerHTML, markdown: '', ...(cover ? { cover } : {}), ...(videos.length ? { videos } : {}) }
125
+ if (Buffer.byteLength(JSON.stringify(article)) > ARTICLE_LIMIT) throw new Error('稿件超过 24 MB,请压缩图片')
126
+ return article
127
+ }
@@ -0,0 +1,298 @@
1
+ /** Local bridge, based on Wechatsync's stdio → WebSocket architecture (MIT).
2
+ * WriteDock uses one loopback port, authenticated forwarding and mutual HMAC pairing.
3
+ */
4
+ import http from 'node:http'
5
+ import { createCipheriv, createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
6
+ import { WebSocket, WebSocketServer } from 'ws'
7
+ import { MediaStore, type MediaAsset } from './media'
8
+ import { mcpReleaseInfo } from './release-info'
9
+
10
+ const LIMIT = 32 * 1024 * 1024
11
+ const messageOf = (error: unknown) => error instanceof Error ? error.message : String(error)
12
+ export const proof = (token: string, role: string, nonce: string) => createHmac('sha256', token).update(`${role}:${nonce}`).digest('hex')
13
+ const digest = (body: string | Buffer) => createHash('sha256').update(body).digest('hex')
14
+ export const requestProof = (token: string, method: string, path: string, nonce: string, time: string, body = '') => proof(token, `http-request:${method}:${path}:${time}:${digest(body)}`, nonce)
15
+ function equal(a: string, b: string) {
16
+ const left = Buffer.from(a); const right = Buffer.from(b)
17
+ return left.length === right.length && timingSafeEqual(left, right)
18
+ }
19
+
20
+ export class LocalBridge {
21
+ private server?: http.Server
22
+ private sockets?: WebSocketServer
23
+ private client?: WebSocket
24
+ private heartbeat?: ReturnType<typeof setInterval>
25
+ private starting?: Promise<void>
26
+ private primary = false
27
+ private extensionId?: string
28
+ private seenNonces = new Map<string, number>()
29
+ private pairing?: { code: string; expiresAt: number; attempts: number }
30
+ private extensionToken: string
31
+ private media: MediaStore
32
+ private pending = new Map<string, { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }>()
33
+ readonly port: number
34
+ constructor(private options: { token: string; port?: number; timeoutMs?: number; credentials?: { getExtensionToken: () => Promise<string>; setExtensionToken: (token: string) => Promise<void> } }) {
35
+ this.port = options.port ?? 9647
36
+ this.extensionToken = options.token
37
+ this.media = new MediaStore(this.port)
38
+ if (!Number.isInteger(this.port) || this.port < 1024 || this.port > 65535) throw new Error('WRITEDOCK_PORT 必须是 1024–65535 的整数')
39
+ if (!/^[A-Za-z0-9_-]{24,128}$/.test(options.token)) throw new Error('MCP 本机身份密钥格式不正确')
40
+ }
41
+
42
+ async start(): Promise<void> {
43
+ if (this.starting) return this.starting
44
+ this.starting = this.listen().catch(async error => {
45
+ if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw error
46
+ this.server = undefined
47
+ this.sockets = undefined
48
+ // Only attach to a broker that proves it holds this pairing key.
49
+ await this.httpCall('/status')
50
+ }).catch(error => { this.starting = undefined; throw error })
51
+ return this.starting
52
+ }
53
+
54
+ private async listen(): Promise<void> {
55
+ if (this.options.credentials) this.extensionToken = await this.options.credentials.getExtensionToken()
56
+ const server = http.createServer((req, res) => { void this.handleHttp(req, res) })
57
+ const sockets = new WebSocketServer({ noServer: true, maxPayload: LIMIT })
58
+ this.server = server; this.sockets = sockets
59
+ server.on('upgrade', (req, socket, head) => {
60
+ const origin = req.headers.origin
61
+ const extensionOrigin = typeof origin === 'string' && /^chrome-extension:\/\/[a-p]{32}$/.test(origin)
62
+ if (!this.validHost(req.headers.host) || !['/extension', '/pair'].includes(req.url || '') || (origin && !extensionOrigin) || (req.url === '/pair' && !extensionOrigin)) {
63
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); socket.destroy(); return
64
+ }
65
+ sockets.handleUpgrade(req, socket, head, ws => { if (req.url === '/pair') this.acceptPairing(ws); else this.accept(ws) })
66
+ })
67
+ await new Promise<void>((resolve, reject) => {
68
+ server.once('error', reject)
69
+ server.listen(this.port, '127.0.0.1', () => { server.off('error', reject); resolve() })
70
+ })
71
+ this.primary = true
72
+ mcpReleaseInfo()
73
+ this.heartbeat = setInterval(() => {
74
+ const release = mcpReleaseInfo()
75
+ if (this.client?.readyState === WebSocket.OPEN) this.client.send(JSON.stringify({ type: 'ping', ...release }))
76
+ }, 20_000)
77
+ this.heartbeat.unref()
78
+ }
79
+
80
+ private validHost(host?: string) { return host === `127.0.0.1:${this.port}` || host === `localhost:${this.port}` }
81
+
82
+ async beginPairing(): Promise<Record<string, unknown>> {
83
+ const state = await this.status() // Also acquire the vacant bridge after another MCP client exits.
84
+ if (!this.primary && state.pairing_available !== true) throw new Error('本机端口仍由旧版 MCP 占用。请重启仍使用旧版本的 agent/MCP 进程,保留原配置后再生成配对码。')
85
+ if (!this.primary) return this.httpCall('/pairing', {})
86
+ const secret = randomBytes(8).toString('hex').toUpperCase().match(/.{4}/g)!.join('-')
87
+ const code = `WD1-${this.port}-${secret}`
88
+ const expiresAt = Date.now() + 180_000
89
+ this.pairing = { code, expiresAt, attempts: 0 }
90
+ return { pairing_code: code, expires_at: new Date(expiresAt).toISOString(), expires_in_seconds: 180, instructions: '将此一次性配对码粘贴到扩展的「MCP 连接 → 输入一次性配对码」。有效期 3 分钟,使用一次后失效。无需重新安装 MCP 或修改 agent 配置。确认用户要连接当前扩展后再生成。' }
91
+ }
92
+
93
+ private acceptPairing(ws: WebSocket) {
94
+ const pairing = this.pairing
95
+ if (!pairing || Date.now() > pairing.expiresAt || pairing.attempts >= 8) { ws.close(1008, 'Pairing code expired'); return }
96
+ pairing.attempts++
97
+ const nonce = randomBytes(32).toString('hex')
98
+ const timer = setTimeout(() => ws.close(1008, 'Pairing timed out'), 10_000)
99
+ ws.send(JSON.stringify({ type: 'pairing_challenge', nonce, proof: proof(pairing.code, 'pairing-server', nonce) }))
100
+ let consumed = false
101
+ ws.on('message', raw => {
102
+ void (async () => {
103
+ if (consumed) return
104
+ consumed = true
105
+ const body = raw.toString()
106
+ if (body.length > 2048) throw new Error('Invalid pairing message')
107
+ const message = JSON.parse(body)
108
+ if (this.pairing !== pairing || Date.now() > pairing.expiresAt || message.type !== 'pairing_hello' || typeof message.proof !== 'string' || !equal(message.proof, proof(pairing.code, 'pairing-extension', nonce))) throw new Error('Pairing failed')
109
+ this.pairing = undefined // Consume before the first asynchronous operation.
110
+ const token = randomBytes(32).toString('hex')
111
+ await this.options.credentials?.setExtensionToken(token)
112
+ this.extensionToken = token
113
+ // A deliberate new pairing retires the old extension credential, not the MCP identity.
114
+ const previousClient = this.client
115
+ this.client = undefined; this.extensionId = undefined
116
+ for (const value of this.pending.values()) { clearTimeout(value.timer); value.reject(new Error('扩展配对已更换;请查询原同步任务,不要自动重新提交。')) }
117
+ this.pending.clear()
118
+ previousClient?.close(1000, 'Extension pairing replaced')
119
+ const key = createHmac('sha256', pairing.code).update(`writedock-pairing-key:${nonce}`).digest()
120
+ const iv = randomBytes(12)
121
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
122
+ cipher.setAAD(Buffer.from('writedock-pairing-v1'))
123
+ const ciphertext = Buffer.concat([cipher.update(JSON.stringify({ token, port: this.port, protocol: 'writedock/1' })), cipher.final(), cipher.getAuthTag()])
124
+ clearTimeout(timer)
125
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'pairing_result', iv: iv.toString('base64'), ciphertext: ciphertext.toString('base64') }))
126
+ ws.close(1000)
127
+ })().catch(() => { ws.close(1008, 'Pairing failed') })
128
+ })
129
+ ws.on('close', () => clearTimeout(timer))
130
+ ws.on('error', () => {})
131
+ }
132
+
133
+ private accept(ws: WebSocket) {
134
+ const nonce = randomBytes(32).toString('hex')
135
+ const token = this.extensionToken
136
+ let authenticated = false
137
+ const timer = setTimeout(() => ws.close(1008, 'Pairing timed out'), 8000)
138
+ ws.send(JSON.stringify({ type: 'challenge', nonce, proof: proof(token, 'server', nonce) }))
139
+ ws.on('message', raw => {
140
+ try {
141
+ const message = JSON.parse(raw.toString())
142
+ if (!authenticated) {
143
+ if (token !== this.extensionToken || message.type !== 'hello' || typeof message.proof !== 'string' || !equal(message.proof, proof(token, 'extension', nonce))) {
144
+ ws.close(1008, 'Pairing failed'); return
145
+ }
146
+ if (this.client?.readyState === WebSocket.OPEN) { ws.close(1008, 'An extension is already connected'); return }
147
+ authenticated = true; clearTimeout(timer)
148
+ this.client = ws; this.extensionId = String(message.extensionId || '')
149
+ ws.send(JSON.stringify({ type: 'ready', ...mcpReleaseInfo() }))
150
+ return
151
+ }
152
+ if (message.type === 'pong') return
153
+ const pending = this.pending.get(message.id)
154
+ if (!pending) return
155
+ this.pending.delete(message.id); clearTimeout(pending.timer)
156
+ if (message.error) pending.reject(new Error(message.error.message || '扩展操作失败'))
157
+ else pending.resolve(message.result)
158
+ } catch { ws.close(1008, 'Invalid message') }
159
+ })
160
+ ws.on('close', () => {
161
+ clearTimeout(timer)
162
+ if (this.client !== ws) return
163
+ this.client = undefined; this.extensionId = undefined
164
+ for (const value of this.pending.values()) {
165
+ clearTimeout(value.timer)
166
+ value.reject(new Error('扩展连接已断开。若已提交同步,请按任务 ID 查询结果,不要更换 request_id 自动重发。'))
167
+ }
168
+ this.pending.clear()
169
+ })
170
+ ws.on('error', () => {})
171
+ }
172
+
173
+ private async handleHttp(req: http.IncomingMessage, res: http.ServerResponse) {
174
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
175
+ res.setHeader('Cache-Control', 'no-store')
176
+ let responseNonce: string | undefined
177
+ const send = (status: number, value: unknown) => {
178
+ const body = JSON.stringify(value)
179
+ if (responseNonce) res.setHeader('X-Writedock-Proof', proof(this.options.token, `http-response:${digest(body)}`, responseNonce))
180
+ res.writeHead(status); res.end(body)
181
+ }
182
+ const assetPath = /^\/assets\/([a-f0-9-]{36})$/.exec(req.url || '')
183
+ if (assetPath && this.validHost(req.headers.host) && (!req.headers.origin || /^chrome-extension:\/\/[a-p]{32}$/.test(req.headers.origin))) {
184
+ await this.media.serve(req, res, assetPath[1]); return
185
+ }
186
+ if (!this.validHost(req.headers.host) || req.headers.origin !== undefined) {
187
+ send(403, { error: '本地桥接认证失败,请检查连接密钥和端口' }); return
188
+ }
189
+ try {
190
+ const chunks: Buffer[] = []; let size = 0
191
+ for await (const chunk of req) {
192
+ size += chunk.length
193
+ if (size > LIMIT) { send(413, { error: '稿件过大,请压缩图片' }); return }
194
+ chunks.push(chunk)
195
+ }
196
+ const body = Buffer.concat(chunks).toString()
197
+ const nonce = String(req.headers['x-writedock-nonce'] || '')
198
+ const time = String(req.headers['x-writedock-time'] || '')
199
+ const signature = String(req.headers['x-writedock-proof'] || '')
200
+ const now = Date.now()
201
+ if (!/^[a-f0-9]{64}$/.test(nonce) || !/^\d+$/.test(time) || Math.abs(now - Number(time)) > 15_000 || this.seenNonces.has(nonce) || !equal(signature, requestProof(this.options.token, req.method || '', req.url || '', nonce, time, body))) {
202
+ send(403, { error: '本地桥接认证失败,请检查连接密钥和端口' }); return
203
+ }
204
+ for (const [key, expires] of this.seenNonces) if (expires < now) this.seenNonces.delete(key)
205
+ this.seenNonces.set(nonce, now + 30_000)
206
+ responseNonce = nonce
207
+ if (req.method === 'GET' && req.url === '/status') {
208
+ send(200, { connected: this.client?.readyState === WebSocket.OPEN, extensionId: this.extensionId, protocol: 'writedock/1', pairing_available: true, ...mcpReleaseInfo() }); return
209
+ }
210
+ if (req.method === 'POST' && req.url === '/pairing') { send(200, await this.beginPairing()); return }
211
+ if (req.method === 'POST' && req.url === '/assets/register') {
212
+ const input = JSON.parse(body)
213
+ if (typeof input.path !== 'string' || !['image', 'video'].includes(input.kind)) throw new Error('素材参数不正确')
214
+ send(200, await this.media.register(input.path, input.kind)); return
215
+ }
216
+ if (req.method !== 'POST' || req.url !== '/request') { send(404, { error: 'Not found' }); return }
217
+ const input = JSON.parse(body)
218
+ if (typeof input.method !== 'string' || !input.params || typeof input.params !== 'object' || Array.isArray(input.params)) throw new Error('无效的桥接请求')
219
+ const result = await this.requestConnected(input.method, input.params)
220
+ send(200, { result })
221
+ } catch (error) { send(400, { error: messageOf(error) }) }
222
+ }
223
+
224
+ private async httpCall(path: string, body?: unknown): Promise<Record<string, unknown>> {
225
+ const nonce = randomBytes(32).toString('hex'), time = String(Date.now())
226
+ const method = body === undefined ? 'GET' : 'POST'
227
+ const requestBody = body === undefined ? '' : JSON.stringify(body)
228
+ const response = await fetch(`http://127.0.0.1:${this.port}${path}`, {
229
+ method,
230
+ headers: { 'X-Writedock-Nonce': nonce, 'X-Writedock-Time': time, 'X-Writedock-Proof': requestProof(this.options.token, method, path, nonce, time, requestBody), 'Content-Type': 'application/json' },
231
+ ...(body === undefined ? {} : { body: requestBody }),
232
+ signal: AbortSignal.timeout(this.options.timeoutMs ?? 55_000),
233
+ })
234
+ const text = await response.text()
235
+ if (!equal(response.headers.get('x-writedock-proof') || '', proof(this.options.token, `http-response:${digest(text)}`, nonce))) throw new Error('本地桥接认证失败,请检查连接密钥和端口')
236
+ const data = JSON.parse(text) as Record<string, unknown>
237
+ if (!response.ok) throw new Error(String(data.error || `本地桥接 HTTP ${response.status}`))
238
+ return data
239
+ }
240
+
241
+ async status() {
242
+ await this.start()
243
+ if (this.primary) return { connected: this.client?.readyState === WebSocket.OPEN, extensionId: this.extensionId, protocol: 'writedock/1', pairing_available: true, ...mcpReleaseInfo() }
244
+ try { return await this.httpCall('/status') }
245
+ catch (error) {
246
+ if (!(error instanceof TypeError)) throw error
247
+ // Read-only health checks can acquire the vacant port after another MCP client exits.
248
+ this.starting = undefined
249
+ await this.start()
250
+ if (this.primary) return { connected: false, protocol: 'writedock/1', pairing_available: true, ...mcpReleaseInfo() }
251
+ throw error
252
+ }
253
+ }
254
+
255
+ async request<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
256
+ await this.start()
257
+ if (this.primary) return this.requestConnected(method, params) as Promise<T>
258
+ try { return (await this.httpCall('/request', { method, params })).result as T }
259
+ catch (error) {
260
+ // Never retry a submitted request. A later tool call may acquire the port after its owner exits.
261
+ if (error instanceof TypeError) this.starting = undefined
262
+ throw error
263
+ }
264
+ }
265
+
266
+ async registerMedia(path: string, kind: 'image' | 'video'): Promise<MediaAsset> {
267
+ await this.status()
268
+ if (this.primary) return this.media.register(path, kind)
269
+ return await this.httpCall('/assets/register', { path, kind }) as unknown as MediaAsset
270
+ }
271
+
272
+ private async requestConnected(method: string, params: Record<string, unknown>): Promise<unknown> {
273
+ if (this.client?.readyState !== WebSocket.OPEN) throw new Error('扩展未连接。请调用 begin_pairing,将一次性配对码粘贴到扩展的 MCP 连接页;无需重装 MCP。')
274
+ const id = randomUUID()
275
+ return new Promise((resolve, reject) => {
276
+ const timer = setTimeout(() => {
277
+ this.pending.delete(id)
278
+ reject(new Error('扩展响应超时;同步任务可能仍在运行,请查询任务状态。'))
279
+ }, this.options.timeoutMs ?? 55_000)
280
+ this.pending.set(id, { resolve, reject, timer })
281
+ this.client!.send(JSON.stringify({ id, method, params }), error => {
282
+ if (!error) return
283
+ clearTimeout(timer); this.pending.delete(id); reject(error)
284
+ })
285
+ })
286
+ }
287
+
288
+ async close() {
289
+ this.pairing = undefined
290
+ clearInterval(this.heartbeat)
291
+ for (const value of this.pending.values()) { clearTimeout(value.timer); value.reject(new Error('MCP 服务已关闭')) }
292
+ this.pending.clear()
293
+ for (const ws of this.sockets?.clients || []) ws.terminate()
294
+ this.sockets?.close()
295
+ if (this.server?.listening) await new Promise<void>(resolve => this.server!.close(() => resolve()))
296
+ this.primary = false; this.starting = undefined
297
+ }
298
+ }