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/LICENSE +674 -0
- package/LICENSE-MULTIPOST +201 -0
- package/NOTICE.md +13 -0
- package/README.md +101 -2
- package/THIRD_PARTY_NOTICES.txt +629 -0
- package/index.cjs +50453 -0
- package/package.json +30 -3
- package/source/distribution.json +5 -0
- package/source/package.json +30 -0
- package/source/src/__tests__/article.test.ts +49 -0
- package/source/src/__tests__/bridge.test.ts +90 -0
- package/source/src/article.ts +127 -0
- package/source/src/bridge.ts +298 -0
- package/source/src/identity.ts +56 -0
- package/source/src/index.ts +27 -0
- package/source/src/media.ts +95 -0
- package/source/src/post.ts +17 -0
- package/source/src/release-info.ts +67 -0
- package/source/src/server.ts +102 -0
- package/source/tsconfig.json +32 -0
- package/source/tsup.config.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "writedock-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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,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: '', 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: '', 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
|
+
}
|