writedock-mcp 0.4.4 → 0.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -20
- package/extension/LICENSE-MULTIPOST.txt +201 -0
- package/extension/LICENSE.txt +674 -0
- package/extension/NOTICE.txt +13 -0
- package/extension/assets/editor-ClIT9WYa.js +76 -0
- package/extension/assets/editor-VBFqqvXW.css +1 -0
- package/extension/assets/icon-128.png +0 -0
- package/extension/assets/icon-16.png +0 -0
- package/extension/assets/icon-48.png +0 -0
- package/extension/assets/index.browser-Hs3ZYqOv.js +157 -0
- package/extension/assets/index.ts-BEVXnN62.js +42 -0
- package/extension/assets/logo.svg +5 -0
- package/extension/assets/offscreen-3BECPbQX.js +63 -0
- package/extension/assets/purify.es-1yEQDPgc.js +3 -0
- package/extension/assets/react-vendor-BrXlnJmF.js +40 -0
- package/extension/assets/task-utils-DwzGcJkm.js +1 -0
- package/extension/manifest.json +40 -0
- package/extension/service-worker-loader.js +1 -0
- package/extension/src/editor/index.html +16 -0
- package/extension/src/offscreen/index.html +6 -0
- package/extension/zhihu-publication-observer.js +1 -0
- package/extension//345/256/211/350/243/205/344/270/216/346/233/264/346/226/260.txt +19 -0
- package/index.cjs +290 -131
- package/package.json +3 -2
- package/source/package.json +1 -1
- package/source/src/__tests__/bridge.test.ts +22 -0
- package/source/src/__tests__/extension-update.test.ts +54 -0
- package/source/src/__tests__/setup.test.ts +50 -0
- package/source/src/bridge.ts +43 -61
- package/source/src/extension-id.ts +2 -0
- package/source/src/extension-update.ts +82 -0
- package/source/src/identity.ts +2 -2
- package/source/src/index.ts +3 -0
- package/source/src/release-info.ts +3 -0
- package/source/src/server.ts +2 -6
- package/source/src/setup.ts +88 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "writedock-mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"description": "WriteDock local MCP server for the browser extension",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"license": "GPL-3.0-only",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"LICENSE-MULTIPOST",
|
|
18
18
|
"NOTICE.md",
|
|
19
19
|
"THIRD_PARTY_NOTICES.txt",
|
|
20
|
-
"source"
|
|
20
|
+
"source",
|
|
21
|
+
"extension"
|
|
21
22
|
],
|
|
22
23
|
"keywords": [
|
|
23
24
|
"mcp",
|
package/source/package.json
CHANGED
|
@@ -2,7 +2,9 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|
|
2
2
|
import { createServer } from 'node:net'
|
|
3
3
|
import { once } from 'node:events'
|
|
4
4
|
import { randomBytes } from 'node:crypto'
|
|
5
|
+
import http from 'node:http'
|
|
5
6
|
import { WebSocket } from 'ws'
|
|
7
|
+
import { EXTENSION_ORIGIN } from '../extension-id'
|
|
6
8
|
import { LocalBridge, proof, requestProof } from '../bridge'
|
|
7
9
|
|
|
8
10
|
const token = 'test-pairing-key-that-is-long-enough'
|
|
@@ -31,6 +33,26 @@ async function pair(bridge: LocalBridge, key = token) {
|
|
|
31
33
|
afterEach(async () => { clients.splice(0).forEach(ws => ws.terminate()); await Promise.all(brokers.splice(0).map(value => value.close())) })
|
|
32
34
|
|
|
33
35
|
describe('authenticated local bridge', () => {
|
|
36
|
+
it('answers discovery and one-click connection only for the fixed extension origin', async () => {
|
|
37
|
+
const bridge = await broker()
|
|
38
|
+
const hello = (origin?: string) => new Promise<number>((resolve, reject) => { const req = http.get({ host: '127.0.0.1', port: bridge.port, path: '/hello', headers: origin ? { Origin: origin } : {} }, res => { res.resume(); resolve(res.statusCode!) }); req.on('error', reject) })
|
|
39
|
+
expect(await hello(EXTENSION_ORIGIN)).toBe(200)
|
|
40
|
+
expect(await hello()).toBe(200)
|
|
41
|
+
expect(await hello('https://example.com')).not.toBe(200)
|
|
42
|
+
expect(await hello('chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')).not.toBe(200)
|
|
43
|
+
const open = (origin: string) => new Promise<{ code?: number; message?: Record<string, unknown> }>(resolve => {
|
|
44
|
+
const ws = new WebSocket(`ws://127.0.0.1:${bridge.port}/connect`, { origin })
|
|
45
|
+
ws.on('message', raw => resolve({ message: JSON.parse(raw.toString()) }))
|
|
46
|
+
ws.on('unexpected-response', (_req, res) => resolve({ code: res.statusCode }))
|
|
47
|
+
ws.on('error', () => {})
|
|
48
|
+
})
|
|
49
|
+
expect((await open('chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')).code).toBe(403)
|
|
50
|
+
expect((await open('https://example.com')).code).toBe(403)
|
|
51
|
+
const connected = await open(EXTENSION_ORIGIN)
|
|
52
|
+
expect(connected.message).toMatchObject({ type: 'connect_result', port: bridge.port, protocol: 'writedock/1' })
|
|
53
|
+
expect(String(connected.message?.token)).toMatch(/^[a-f0-9]{64}$/)
|
|
54
|
+
})
|
|
55
|
+
|
|
34
56
|
it('rejects unauthenticated HTTP calls and browser-origin requests', async () => {
|
|
35
57
|
const bridge = await broker()
|
|
36
58
|
const url = `http://127.0.0.1:${bridge.port}/status`
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { bundledExtension, findInstalledExtension, installExtensionFiles } from '../extension-update'
|
|
6
|
+
|
|
7
|
+
const manifest = (version: string, extra: Record<string, unknown> = {}) => JSON.stringify({ name: '写作台 WriteDock', version, key: 'fixed-key', ...extra })
|
|
8
|
+
|
|
9
|
+
describe('extension update', () => {
|
|
10
|
+
let work: string, source: string, target: string
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
work = await mkdtemp(join(tmpdir(), 'writedock-ext-'))
|
|
13
|
+
source = join(work, 'bundle/extension'); target = join(work, 'installed')
|
|
14
|
+
await mkdir(source, { recursive: true })
|
|
15
|
+
await writeFile(join(source, 'manifest.json'), manifest('2.0.0'))
|
|
16
|
+
await writeFile(join(source, 'worker.js'), 'new')
|
|
17
|
+
})
|
|
18
|
+
afterEach(() => rm(work, { recursive: true, force: true }))
|
|
19
|
+
|
|
20
|
+
it('installs into an empty folder, then overwrites in place keeping files still in use', async () => {
|
|
21
|
+
expect(await installExtensionFiles(source, target)).toMatchObject({ version: '2.0.0' })
|
|
22
|
+
expect(await readFile(join(target, 'worker.js'), 'utf8')).toBe('new')
|
|
23
|
+
await writeFile(join(target, 'old-hashed.js'), 'still used until reload')
|
|
24
|
+
await writeFile(join(target, 'manifest.json'), manifest('1.0.0'))
|
|
25
|
+
await installExtensionFiles(source, target)
|
|
26
|
+
expect(JSON.parse(await readFile(join(target, 'manifest.json'), 'utf8')).version).toBe('2.0.0')
|
|
27
|
+
expect(await readFile(join(target, 'old-hashed.js'), 'utf8')).toBe('still used until reload')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('refuses another extension or a different identity, and leaves the folder alone', async () => {
|
|
31
|
+
await mkdir(target)
|
|
32
|
+
await writeFile(join(target, 'manifest.json'), JSON.stringify({ name: 'Other', version: '1.0.0' }))
|
|
33
|
+
await expect(installExtensionFiles(source, target)).rejects.toThrow('不是 WriteDock')
|
|
34
|
+
expect(JSON.parse(await readFile(join(target, 'manifest.json'), 'utf8')).name).toBe('Other')
|
|
35
|
+
await writeFile(join(target, 'manifest.json'), manifest('1.0.0', { key: 'other-key' }))
|
|
36
|
+
await expect(installExtensionFiles(source, target)).rejects.toThrow('身份不同')
|
|
37
|
+
expect(JSON.parse(await readFile(join(target, 'manifest.json'), 'utf8')).version).toBe('1.0.0')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('finds the loaded folder by extension ID and ignores others', async () => {
|
|
41
|
+
await installExtensionFiles(source, target)
|
|
42
|
+
const browser = join(work, 'browser')
|
|
43
|
+
await mkdir(join(browser, 'Default'), { recursive: true })
|
|
44
|
+
await writeFile(join(browser, 'Default/Preferences'), JSON.stringify({ extensions: { settings: { other: { path: join(work, 'nope') }, theid: { path: target } } } }))
|
|
45
|
+
expect(await findInstalledExtension([browser], 'theid')).toBe(await realpath(target))
|
|
46
|
+
expect(await findInstalledExtension([browser], 'missing')).toBeUndefined()
|
|
47
|
+
expect(await findInstalledExtension([join(work, 'no-browser')], 'theid')).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('reads the bundled version, and reports none from a source checkout', () => {
|
|
51
|
+
expect(bundledExtension(join(work, 'bundle'))).toMatchObject({ version: '2.0.0' })
|
|
52
|
+
expect(bundledExtension(work)).toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { runSetup } from '../setup'
|
|
6
|
+
|
|
7
|
+
describe('setup', () => {
|
|
8
|
+
let home: string
|
|
9
|
+
const log: string[] = []
|
|
10
|
+
beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'writedock-setup-')); log.length = 0 })
|
|
11
|
+
afterEach(() => rm(home, { recursive: true, force: true }))
|
|
12
|
+
|
|
13
|
+
it('adds WriteDock to Codex and keeps the rest of the file, with a backup', async () => {
|
|
14
|
+
await mkdir(join(home, '.codex'))
|
|
15
|
+
const original = 'model = "x"\n\n[mcp_servers.other]\ncommand = "other"\n'
|
|
16
|
+
await writeFile(join(home, '.codex/config.toml'), original)
|
|
17
|
+
expect(await runSetup(home, line => log.push(line))).toBe(1)
|
|
18
|
+
const result = await readFile(join(home, '.codex/config.toml'), 'utf8')
|
|
19
|
+
expect(result.startsWith(original.trimEnd())).toBe(true)
|
|
20
|
+
expect(result).toContain('[mcp_servers.writedock]')
|
|
21
|
+
expect(result).toContain('"writedock-mcp@latest"')
|
|
22
|
+
expect(await readFile(join(home, '.codex/config.toml.writedock-backup'), 'utf8')).toBe(original)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('leaves an existing entry alone, including its custom port and token', async () => {
|
|
26
|
+
await mkdir(join(home, '.codex'))
|
|
27
|
+
const original = '[mcp_servers.writedock]\ncommand = "node"\n\n[mcp_servers.writedock.env]\nWRITEDOCK_PORT = "9700"\n'
|
|
28
|
+
await writeFile(join(home, '.codex/config.toml'), original)
|
|
29
|
+
await runSetup(home, line => log.push(line))
|
|
30
|
+
expect(await readFile(join(home, '.codex/config.toml'), 'utf8')).toBe(original)
|
|
31
|
+
expect(log.join()).toContain('未改动')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('merges JSON clients, and reports a broken file without changing it', async () => {
|
|
35
|
+
await writeFile(join(home, '.claude.json'), JSON.stringify({ theme: 'dark', mcpServers: { other: { command: 'x' } } }))
|
|
36
|
+
await mkdir(join(home, '.cursor'))
|
|
37
|
+
await writeFile(join(home, '.cursor/mcp.json'), '{ broken')
|
|
38
|
+
await runSetup(home, line => log.push(line))
|
|
39
|
+
const claude = JSON.parse(await readFile(join(home, '.claude.json'), 'utf8'))
|
|
40
|
+
expect(claude.theme).toBe('dark')
|
|
41
|
+
expect(Object.keys(claude.mcpServers).sort()).toEqual(['other', 'writedock'])
|
|
42
|
+
expect(await readFile(join(home, '.cursor/mcp.json'), 'utf8')).toBe('{ broken')
|
|
43
|
+
expect(log.join()).toContain('配置失败')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('touches nothing when no client exists, and prints the configuration', async () => {
|
|
47
|
+
expect(await runSetup(home, line => log.push(line))).toBe(0)
|
|
48
|
+
expect(log.join()).toContain('writedock-mcp@latest')
|
|
49
|
+
})
|
|
50
|
+
})
|
package/source/src/bridge.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/** Local bridge, based on Wechatsync's stdio → WebSocket architecture (MIT).
|
|
2
|
-
* WriteDock uses one loopback port, authenticated forwarding and
|
|
2
|
+
* WriteDock uses one loopback port, authenticated forwarding and an HMAC handshake.
|
|
3
3
|
*/
|
|
4
4
|
import http from 'node:http'
|
|
5
|
-
import {
|
|
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
8
|
import { mcpReleaseInfo } from './release-info'
|
|
9
|
+
import { EXTENSION_ORIGIN } from './extension-id'
|
|
10
|
+
import { updateInstalledExtension } from './extension-update'
|
|
9
11
|
|
|
10
12
|
const LIMIT = 32 * 1024 * 1024
|
|
11
13
|
const messageOf = (error: unknown) => error instanceof Error ? error.message : String(error)
|
|
@@ -26,7 +28,6 @@ export class LocalBridge {
|
|
|
26
28
|
private primary = false
|
|
27
29
|
private extensionId?: string
|
|
28
30
|
private seenNonces = new Map<string, number>()
|
|
29
|
-
private pairing?: { code: string; expiresAt: number; attempts: number }
|
|
30
31
|
private extensionToken: string
|
|
31
32
|
private media: MediaStore
|
|
32
33
|
private pending = new Map<string, { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }>()
|
|
@@ -45,7 +46,7 @@ export class LocalBridge {
|
|
|
45
46
|
if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw error
|
|
46
47
|
this.server = undefined
|
|
47
48
|
this.sockets = undefined
|
|
48
|
-
// Only attach to a broker that proves it holds this
|
|
49
|
+
// Only attach to a broker that proves it holds this key.
|
|
49
50
|
await this.httpCall('/status')
|
|
50
51
|
}).catch(error => { this.starting = undefined; throw error })
|
|
51
52
|
return this.starting
|
|
@@ -59,10 +60,10 @@ export class LocalBridge {
|
|
|
59
60
|
server.on('upgrade', (req, socket, head) => {
|
|
60
61
|
const origin = req.headers.origin
|
|
61
62
|
const extensionOrigin = typeof origin === 'string' && /^chrome-extension:\/\/[a-p]{32}$/.test(origin)
|
|
62
|
-
if (!this.validHost(req.headers.host) || !['/extension', '/
|
|
63
|
+
if (!this.validHost(req.headers.host) || !['/extension', '/connect'].includes(req.url || '') || (origin && !extensionOrigin) || (req.url === '/connect' && origin !== EXTENSION_ORIGIN)) {
|
|
63
64
|
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); socket.destroy(); return
|
|
64
65
|
}
|
|
65
|
-
sockets.handleUpgrade(req, socket, head, ws => { if (req.url === '/
|
|
66
|
+
sockets.handleUpgrade(req, socket, head, ws => { if (req.url === '/connect') void this.acceptConnect(ws); else this.accept(ws) })
|
|
66
67
|
})
|
|
67
68
|
await new Promise<void>((resolve, reject) => {
|
|
68
69
|
server.once('error', reject)
|
|
@@ -79,69 +80,40 @@ export class LocalBridge {
|
|
|
79
80
|
|
|
80
81
|
private validHost(host?: string) { return host === `127.0.0.1:${this.port}` || host === `localhost:${this.port}` }
|
|
81
82
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
this.
|
|
90
|
-
|
|
83
|
+
/** Replaces the extension credential and drops whatever was using the old one. */
|
|
84
|
+
private async replaceExtensionToken(reason: string) {
|
|
85
|
+
const token = randomBytes(32).toString('hex')
|
|
86
|
+
await this.options.credentials?.setExtensionToken(token)
|
|
87
|
+
this.extensionToken = token
|
|
88
|
+
const previousClient = this.client
|
|
89
|
+
this.client = undefined; this.extensionId = undefined
|
|
90
|
+
for (const value of this.pending.values()) { clearTimeout(value.timer); value.reject(new Error(reason)) }
|
|
91
|
+
this.pending.clear()
|
|
92
|
+
previousClient?.close(1000, 'Extension connection replaced')
|
|
93
|
+
return token
|
|
91
94
|
}
|
|
92
95
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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', () => {})
|
|
96
|
+
/** One-click connection: only this extension's fixed origin can reach here, and the user has just clicked 连接. */
|
|
97
|
+
private async acceptConnect(ws: WebSocket) {
|
|
98
|
+
try {
|
|
99
|
+
const token = await this.replaceExtensionToken('扩展连接已更换;请查询原同步任务,不要自动重新提交。')
|
|
100
|
+
ws.send(JSON.stringify({ type: 'connect_result', token, port: this.port, protocol: 'writedock/1' }))
|
|
101
|
+
ws.close(1000)
|
|
102
|
+
} catch { ws.close(1011, 'Connect failed') }
|
|
131
103
|
}
|
|
132
104
|
|
|
133
105
|
private accept(ws: WebSocket) {
|
|
134
106
|
const nonce = randomBytes(32).toString('hex')
|
|
135
107
|
const token = this.extensionToken
|
|
136
108
|
let authenticated = false
|
|
137
|
-
const timer = setTimeout(() => ws.close(1008, '
|
|
109
|
+
const timer = setTimeout(() => ws.close(1008, 'Handshake timed out'), 8000)
|
|
138
110
|
ws.send(JSON.stringify({ type: 'challenge', nonce, proof: proof(token, 'server', nonce) }))
|
|
139
111
|
ws.on('message', raw => {
|
|
140
112
|
try {
|
|
141
113
|
const message = JSON.parse(raw.toString())
|
|
142
114
|
if (!authenticated) {
|
|
143
115
|
if (token !== this.extensionToken || message.type !== 'hello' || typeof message.proof !== 'string' || !equal(message.proof, proof(token, 'extension', nonce))) {
|
|
144
|
-
ws.close(1008, '
|
|
116
|
+
ws.close(1008, 'Handshake failed'); return
|
|
145
117
|
}
|
|
146
118
|
if (this.client?.readyState === WebSocket.OPEN) { ws.close(1008, 'An extension is already connected'); return }
|
|
147
119
|
authenticated = true; clearTimeout(timer)
|
|
@@ -150,6 +122,13 @@ export class LocalBridge {
|
|
|
150
122
|
return
|
|
151
123
|
}
|
|
152
124
|
if (message.type === 'pong') return
|
|
125
|
+
if (message.type === 'update_extension') {
|
|
126
|
+
void updateInstalledExtension().then(
|
|
127
|
+
result => ws.send(JSON.stringify({ type: 'update_extension_result', ok: true, version: result.version })),
|
|
128
|
+
error => ws.send(JSON.stringify({ type: 'update_extension_result', ok: false, error: messageOf(error) })),
|
|
129
|
+
)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
153
132
|
const pending = this.pending.get(message.id)
|
|
154
133
|
if (!pending) return
|
|
155
134
|
this.pending.delete(message.id); clearTimeout(pending.timer)
|
|
@@ -179,6 +158,11 @@ export class LocalBridge {
|
|
|
179
158
|
if (responseNonce) res.setHeader('X-Writedock-Proof', proof(this.options.token, `http-response:${digest(body)}`, responseNonce))
|
|
180
159
|
res.writeHead(status); res.end(body)
|
|
181
160
|
}
|
|
161
|
+
// Lets the extension find this service before it is connected. Reveals only that it exists and its version.
|
|
162
|
+
// Chrome omits Origin on GETs from an extension with host permission; web pages always send theirs.
|
|
163
|
+
if (req.url === '/hello' && req.method === 'GET' && this.validHost(req.headers.host) && (!req.headers.origin || req.headers.origin === EXTENSION_ORIGIN)) {
|
|
164
|
+
send(200, { service: 'writedock-mcp', protocol: 'writedock/1', version: mcpReleaseInfo().mcp_version }); return
|
|
165
|
+
}
|
|
182
166
|
const assetPath = /^\/assets\/([a-f0-9-]{36})$/.exec(req.url || '')
|
|
183
167
|
if (assetPath && this.validHost(req.headers.host) && (!req.headers.origin || /^chrome-extension:\/\/[a-p]{32}$/.test(req.headers.origin))) {
|
|
184
168
|
await this.media.serve(req, res, assetPath[1]); return
|
|
@@ -205,9 +189,8 @@ export class LocalBridge {
|
|
|
205
189
|
this.seenNonces.set(nonce, now + 30_000)
|
|
206
190
|
responseNonce = nonce
|
|
207
191
|
if (req.method === 'GET' && req.url === '/status') {
|
|
208
|
-
send(200, { connected: this.client?.readyState === WebSocket.OPEN, extensionId: this.extensionId, protocol: 'writedock/1',
|
|
192
|
+
send(200, { connected: this.client?.readyState === WebSocket.OPEN, extensionId: this.extensionId, protocol: 'writedock/1', ...mcpReleaseInfo() }); return
|
|
209
193
|
}
|
|
210
|
-
if (req.method === 'POST' && req.url === '/pairing') { send(200, await this.beginPairing()); return }
|
|
211
194
|
if (req.method === 'POST' && req.url === '/assets/register') {
|
|
212
195
|
const input = JSON.parse(body)
|
|
213
196
|
if (typeof input.path !== 'string' || !['image', 'video'].includes(input.kind)) throw new Error('素材参数不正确')
|
|
@@ -240,14 +223,14 @@ export class LocalBridge {
|
|
|
240
223
|
|
|
241
224
|
async status() {
|
|
242
225
|
await this.start()
|
|
243
|
-
if (this.primary) return { connected: this.client?.readyState === WebSocket.OPEN, extensionId: this.extensionId, protocol: 'writedock/1',
|
|
226
|
+
if (this.primary) return { connected: this.client?.readyState === WebSocket.OPEN, extensionId: this.extensionId, protocol: 'writedock/1', ...mcpReleaseInfo() }
|
|
244
227
|
try { return await this.httpCall('/status') }
|
|
245
228
|
catch (error) {
|
|
246
229
|
if (!(error instanceof TypeError)) throw error
|
|
247
230
|
// Read-only health checks can acquire the vacant port after another MCP client exits.
|
|
248
231
|
this.starting = undefined
|
|
249
232
|
await this.start()
|
|
250
|
-
if (this.primary) return { connected: false, protocol: 'writedock/1',
|
|
233
|
+
if (this.primary) return { connected: false, protocol: 'writedock/1', ...mcpReleaseInfo() }
|
|
251
234
|
throw error
|
|
252
235
|
}
|
|
253
236
|
}
|
|
@@ -270,7 +253,7 @@ export class LocalBridge {
|
|
|
270
253
|
}
|
|
271
254
|
|
|
272
255
|
private async requestConnected(method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
273
|
-
if (this.client?.readyState !== WebSocket.OPEN) throw new Error('
|
|
256
|
+
if (this.client?.readyState !== WebSocket.OPEN) throw new Error('扩展未连接。请让用户打开浏览器的 WriteDock 扩展,在「连接」页点“连接”。无需重装 MCP。')
|
|
274
257
|
const id = randomUUID()
|
|
275
258
|
return new Promise((resolve, reject) => {
|
|
276
259
|
const timer = setTimeout(() => {
|
|
@@ -286,7 +269,6 @@ export class LocalBridge {
|
|
|
286
269
|
}
|
|
287
270
|
|
|
288
271
|
async close() {
|
|
289
|
-
this.pairing = undefined
|
|
290
272
|
clearInterval(this.heartbeat)
|
|
291
273
|
for (const value of this.pending.values()) { clearTimeout(value.timer); value.reject(new Error('MCP 服务已关闭')) }
|
|
292
274
|
this.pending.clear()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { cp, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { homedir, tmpdir } from 'node:os'
|
|
4
|
+
import { isAbsolute, join } from 'node:path'
|
|
5
|
+
import { EXTENSION_ORIGIN } from './extension-id'
|
|
6
|
+
|
|
7
|
+
const EXTENSION_ID = EXTENSION_ORIGIN.replace('chrome-extension://', '')
|
|
8
|
+
const readManifest = async (directory: string) => JSON.parse(await readFile(join(directory, 'manifest.json'), 'utf8')) as { name?: string; version?: string; key?: string }
|
|
9
|
+
const isWriteDock = (manifest: { name?: string; manifest_version?: number }) => manifest.name === '写作台 WriteDock'
|
|
10
|
+
|
|
11
|
+
/** The extension shipped inside this npm package. Absent when running from a source checkout. */
|
|
12
|
+
export function bundledExtension(base = __dirname) {
|
|
13
|
+
try {
|
|
14
|
+
const directory = join(base, 'extension')
|
|
15
|
+
const manifest = JSON.parse(readFileSync(join(directory, 'manifest.json'), 'utf8')) as { version?: string }
|
|
16
|
+
return typeof manifest.version === 'string' ? { directory, version: manifest.version } : undefined
|
|
17
|
+
} catch { return undefined }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Where setup installs the extension: stable, and visible in the browser's folder picker, which hides ~/Library on macOS. */
|
|
21
|
+
export const defaultExtensionDirectory = (home = homedir()) => join(home, 'WriteDock', 'extension')
|
|
22
|
+
|
|
23
|
+
function browserRoots(user = homedir()) {
|
|
24
|
+
const names = process.platform === 'win32'
|
|
25
|
+
? ['Google/Chrome/User Data', 'Microsoft/Edge/User Data', 'Chromium/User Data', 'BraveSoftware/Brave-Browser/User Data'].map(name => join(process.env.LOCALAPPDATA || join(user, 'AppData/Local'), name))
|
|
26
|
+
: process.platform === 'darwin'
|
|
27
|
+
? ['Google/Chrome', 'Microsoft Edge', 'Chromium', 'BraveSoftware/Brave-Browser'].map(name => join(user, 'Library/Application Support', name))
|
|
28
|
+
: ['google-chrome', 'microsoft-edge', 'chromium', 'BraveSoftware/Brave-Browser'].map(name => join(process.env.XDG_CONFIG_HOME || join(user, '.config'), name))
|
|
29
|
+
return names
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Finds the folder the browser actually loaded this extension from, by its fixed ID. */
|
|
33
|
+
export async function findInstalledExtension(roots = browserRoots(), id = EXTENSION_ID) {
|
|
34
|
+
for (const root of roots) {
|
|
35
|
+
let profiles: { name: string; isDirectory: () => boolean }[]
|
|
36
|
+
try { profiles = await readdir(root, { withFileTypes: true }) } catch { continue }
|
|
37
|
+
for (const profile of profiles.filter(item => item.isDirectory() && /^(Default|Profile \d+)$/.test(item.name))) {
|
|
38
|
+
for (const filename of ['Secure Preferences', 'Preferences']) {
|
|
39
|
+
try {
|
|
40
|
+
const preferences = JSON.parse(await readFile(join(root, profile.name, filename), 'utf8'))
|
|
41
|
+
const path = preferences.extensions?.settings?.[id]?.path
|
|
42
|
+
if (typeof path === 'string' && isAbsolute(path) && isWriteDock(await readManifest(path))) return realpath(path)
|
|
43
|
+
} catch { /* Missing or unreadable profile. */ }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Overwrites in place, manifest last, restoring the previous files if anything fails. */
|
|
51
|
+
export async function installExtensionFiles(source: string, target: string) {
|
|
52
|
+
const next = await readManifest(source)
|
|
53
|
+
if (!isWriteDock(next)) throw new Error('内置扩展文件不正确')
|
|
54
|
+
let previous: Awaited<ReturnType<typeof readManifest>> | undefined
|
|
55
|
+
try { previous = await readManifest(target) } catch { /* A first install. */ }
|
|
56
|
+
if (previous) {
|
|
57
|
+
if (!isWriteDock(previous)) throw new Error('目标目录不是 WriteDock 扩展,未覆盖')
|
|
58
|
+
if (previous.key !== next.key) throw new Error('旧版扩展的身份不同,无法原位更新,请重新加载一次新版')
|
|
59
|
+
}
|
|
60
|
+
await mkdir(target, { recursive: true })
|
|
61
|
+
const backup = previous ? await mkdtemp(join(tmpdir(), 'writedock-extension-backup-')) : undefined
|
|
62
|
+
if (backup) await cp(target, backup, { recursive: true, force: true })
|
|
63
|
+
try {
|
|
64
|
+
await cp(source, target, { recursive: true, force: true, filter: path => path !== join(source, 'manifest.json') })
|
|
65
|
+
const staged = join(target, '.writedock-manifest-update.tmp')
|
|
66
|
+
await writeFile(staged, await readFile(join(source, 'manifest.json')))
|
|
67
|
+
await rename(staged, join(target, 'manifest.json'))
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (backup) await cp(backup, target, { recursive: true, force: true })
|
|
70
|
+
throw error
|
|
71
|
+
} finally { if (backup) await rm(backup, { recursive: true, force: true }).catch(() => {}) }
|
|
72
|
+
return { directory: target, version: next.version }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Handles the 更新 button in the extension. */
|
|
76
|
+
export async function updateInstalledExtension() {
|
|
77
|
+
const bundle = bundledExtension()
|
|
78
|
+
if (!bundle) throw new Error('当前服务没有内置扩展,请重启 agent 后再试')
|
|
79
|
+
const target = await findInstalledExtension()
|
|
80
|
+
if (!target) throw new Error('没有找到已加载的扩展目录,请手动更新')
|
|
81
|
+
return installExtensionFiles(bundle.directory, target)
|
|
82
|
+
}
|
package/source/src/identity.ts
CHANGED
|
@@ -20,7 +20,7 @@ export async function loadIdentity(port: number, legacyToken?: string) {
|
|
|
20
20
|
const filename = join(directory, `identity-${port}.json`)
|
|
21
21
|
const read = async (): Promise<Identity> => {
|
|
22
22
|
const value = JSON.parse(await readFile(filename, 'utf8'))
|
|
23
|
-
if (value.version !== 1 || !validToken(value.token) || !validToken(value.extensionToken)) throw new Error('MCP
|
|
23
|
+
if (value.version !== 1 || !validToken(value.token) || !validToken(value.extensionToken)) throw new Error('MCP 连接资料格式不正确;请保留原文件并检查备份,不会自动覆盖')
|
|
24
24
|
return value
|
|
25
25
|
}
|
|
26
26
|
let identity: Identity
|
|
@@ -43,7 +43,7 @@ export async function loadIdentity(port: number, legacyToken?: string) {
|
|
|
43
43
|
token: identity.token,
|
|
44
44
|
getExtensionToken: async () => (await read()).extensionToken,
|
|
45
45
|
setExtensionToken: async (extensionToken: string) => {
|
|
46
|
-
if (!validToken(extensionToken)) throw new Error('
|
|
46
|
+
if (!validToken(extensionToken)) throw new Error('扩展连接密钥格式不正确')
|
|
47
47
|
const current = await read()
|
|
48
48
|
if (current.token !== identity.token) throw new Error('MCP 身份已变化,请重新启动服务')
|
|
49
49
|
const temporary = join(directory, `.identity-${randomUUID()}.tmp`)
|
package/source/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { LocalBridge } from './bridge'
|
|
|
4
4
|
import { createMcpServer } from './server'
|
|
5
5
|
import { loadIdentity } from './identity'
|
|
6
6
|
import mcpPackage from '../package.json'
|
|
7
|
+
import { runSetup } from './setup'
|
|
7
8
|
|
|
8
9
|
async function main() {
|
|
9
10
|
const port = Number(process.env.WRITEDOCK_PORT || 9647)
|
|
@@ -22,6 +23,8 @@ async function main() {
|
|
|
22
23
|
if (process.argv.slice(2).some(argument => argument === '--version' || argument === '-v')) {
|
|
23
24
|
// Allow inspecting an npm archive without starting a bridge or reading pairing credentials.
|
|
24
25
|
console.log(mcpPackage.version)
|
|
26
|
+
} else if (process.argv[2] === 'setup') {
|
|
27
|
+
runSetup().catch(error => { console.error('[WriteDock MCP]', error instanceof Error ? error.message : String(error)); process.exit(1) })
|
|
25
28
|
} else {
|
|
26
29
|
main().catch(error => { console.error('[WriteDock MCP]', error instanceof Error ? error.message : String(error)); process.exit(1) })
|
|
27
30
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import mcpPackage from '../package.json'
|
|
2
2
|
import distribution from '../distribution.json'
|
|
3
|
+
import { bundledExtension } from './extension-update'
|
|
3
4
|
|
|
4
5
|
const CHECK_INTERVAL = 6 * 60 * 60_000
|
|
5
6
|
const RETRY_INTERVAL = 10 * 60_000
|
|
@@ -56,8 +57,10 @@ function refresh() {
|
|
|
56
57
|
/** Check asynchronously; version lookup must never delay pairing, tool calls or publication. */
|
|
57
58
|
export function mcpReleaseInfo() {
|
|
58
59
|
refresh()
|
|
60
|
+
const bundle = bundledExtension()
|
|
59
61
|
return {
|
|
60
62
|
mcp_version: mcpPackage.version,
|
|
63
|
+
...(bundle ? { extension_bundle_version: bundle.version } : {}),
|
|
61
64
|
...(latestVersion ? { latest_version: latestVersion } : {}),
|
|
62
65
|
update_available: !!latestVersion && newerVersion(latestVersion, mcpPackage.version),
|
|
63
66
|
...(checkedAt ? { update_checked_at: checkedAt } : {}),
|
package/source/src/server.ts
CHANGED
|
@@ -30,7 +30,7 @@ const agentWorkflow = 'Agent 只通过 WriteDock MCP 投稿和查询回执,不
|
|
|
30
30
|
|
|
31
31
|
export function createMcpServer(bridge: LocalBridge) {
|
|
32
32
|
const server = new McpServer({ name: 'writedock', version: mcpPackage.version }, {
|
|
33
|
-
instructions: agentWorkflow + 'WriteDock 通过本机扩展执行投稿。先检查连接和平台登录。实际平台能力以 list_platforms 返回的 action、publishModes、tools、modeOptions、confirmation、capabilities、coverRequired 为准,扩展更新后优先按该元信息选择工具。知乎、百家号、微博长文章、B站专栏、小红书长文使用 publish_article,Agent 直接提供 Markdown/HTML 或文件路径即可发布,无需用户在扩展编辑。百家号必须提供 cover 封面;知乎另支持 <video src="本地MP4路径"></video> 文章内视频,独立封面暂未接入。prepare_article 仅供可选本机预览,不是发布的前置要求。小红书 ID 为 xiaohongshu,三种模式共用此平台:写长文用 publish_article,支持正文图片、原生自动排版后发布,标题最多 64 字、正文最多 10000 字,不接独立 cover 或文章内视频;普通图文用 publish_post(kind=image),独立视频用 publish_post(kind=video),未传 cover_path 时保留平台自动生成的第一帧封面。小红书最终均须匹配 POST /web_api/sns/v2/note 的 success=true、result=0 和笔记 ID 回执才确认 submitted。抖音平台 ID 为 douyin,图文和视频同样用 publish_post(kind=image|video),可选 cover_path;图文话题最多 4 个、视频最多 5 个,当前只接即时发布。抖音 confirmation=native_page_message,以本次操作新增的原生成功提示确认 submitted,未核对最终 HTTP 业务回执;仅跳转作品管理时仍为 unknown,Agent 不自动重发。小红书、抖音、微博普通图文与独立视频使用 prepare_post / publish_post,微博平台 ID 仍为 weibo,不另建平台。微博图文标题放入正文首行,最多 18 张图片;独立视频标题最多 30 个汉字(60 个半角字符),weibo_video_creation 必填,须依据用户实际内容传 original、secondary 或 repost,不能默认原创。微博同一次只能选图文或视频,长文章继续用 publish_article。只有用户明确要求发布并指定平台时才调用发布工具。微信公众号、搜狐号目前用 sync_article 保存文章草稿。B站专栏 ID 为 bilibili,使用 publish_article 直接发布文字和正文图片,不接视频、音频、附加内容卡片或独立封面;通过新版原生编辑器上传并发布,code=-352 时停止自动提交并转人工验证。遇到 needs_user_action 或 requires_user_action 时,把 user_actions 中的原因和稿件链接告诉用户,优先让用户切换到扩展已打开的页面处理验证码、封面、内容声明、来源、原创协议或发布设置,并手动发布;不得自动解决验证码、重新提交或换 request_id。该状态只记录人工接管,不代表用户之后的操作结果已被确认。扩展更新复用已有 MCP
|
|
33
|
+
instructions: agentWorkflow + 'WriteDock 通过本机扩展执行投稿。先检查连接和平台登录。实际平台能力以 list_platforms 返回的 action、publishModes、tools、modeOptions、confirmation、capabilities、coverRequired 为准,扩展更新后优先按该元信息选择工具。知乎、百家号、微博长文章、B站专栏、小红书长文使用 publish_article,Agent 直接提供 Markdown/HTML 或文件路径即可发布,无需用户在扩展编辑。百家号必须提供 cover 封面;知乎另支持 <video src="本地MP4路径"></video> 文章内视频,独立封面暂未接入。prepare_article 仅供可选本机预览,不是发布的前置要求。小红书 ID 为 xiaohongshu,三种模式共用此平台:写长文用 publish_article,支持正文图片、原生自动排版后发布,标题最多 64 字、正文最多 10000 字,不接独立 cover 或文章内视频;普通图文用 publish_post(kind=image),独立视频用 publish_post(kind=video),未传 cover_path 时保留平台自动生成的第一帧封面。小红书最终均须匹配 POST /web_api/sns/v2/note 的 success=true、result=0 和笔记 ID 回执才确认 submitted。抖音平台 ID 为 douyin,图文和视频同样用 publish_post(kind=image|video),可选 cover_path;图文话题最多 4 个、视频最多 5 个,当前只接即时发布。抖音 confirmation=native_page_message,以本次操作新增的原生成功提示确认 submitted,未核对最终 HTTP 业务回执;仅跳转作品管理时仍为 unknown,Agent 不自动重发。小红书、抖音、微博普通图文与独立视频使用 prepare_post / publish_post,微博平台 ID 仍为 weibo,不另建平台。微博图文标题放入正文首行,最多 18 张图片;独立视频标题最多 30 个汉字(60 个半角字符),weibo_video_creation 必填,须依据用户实际内容传 original、secondary 或 repost,不能默认原创。微博同一次只能选图文或视频,长文章继续用 publish_article。只有用户明确要求发布并指定平台时才调用发布工具。微信公众号、搜狐号目前用 sync_article 保存文章草稿。B站专栏 ID 为 bilibili,使用 publish_article 直接发布文字和正文图片,不接视频、音频、附加内容卡片或独立封面;通过新版原生编辑器上传并发布,code=-352 时停止自动提交并转人工验证。遇到 needs_user_action 或 requires_user_action 时,把 user_actions 中的原因和稿件链接告诉用户,优先让用户切换到扩展已打开的页面处理验证码、封面、内容声明、来源、原创协议或发布设置,并手动发布;不得自动解决验证码、重新提交或换 request_id。该状态只记录人工接管,不代表用户之后的操作结果已被确认。扩展更新复用已有 MCP;未连接时,让用户打开 WriteDock 扩展的「连接」页点“连接”。提交返回任务 ID,随后用 get_sync_task 查询结果。保持同一操作的 request_id 不变。publicationState=unknown 或任务中断时,向用户给出链接让其自行核对平台,Agent 不打开网页,不要自动生成新 request_id 重发。直接发布成功表示平台已接受提交,是否公开展示以平台为准。',
|
|
34
34
|
})
|
|
35
35
|
const wrap = (operation: () => Promise<unknown>) => operation().then(result => ({
|
|
36
36
|
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
|
@@ -38,12 +38,8 @@ export function createMcpServer(bridge: LocalBridge) {
|
|
|
38
38
|
const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
|
|
39
39
|
|
|
40
40
|
server.registerTool('get_connection_status', {
|
|
41
|
-
description: '检查本机 MCP 与扩展的连接、运行版本和 npm 更新提示。update_available=true 时告知用户重新启动 WriteDock MCP
|
|
41
|
+
description: '检查本机 MCP 与扩展的连接、运行版本和 npm 更新提示。update_available=true 时告知用户重新启动 WriteDock MCP,保留配置;发布任务进行中不要自动重启。旧版服务可能不返回版本字段;latest_version 缺失或 update_checking=true 时,不得声称已是最新版。', inputSchema: {}, annotations: { ...readOnly, openWorldHint: true },
|
|
42
42
|
}, () => wrap(() => bridge.status()))
|
|
43
|
-
server.registerTool('begin_pairing', {
|
|
44
|
-
description: '首次连接或扩展卸载重装后,生成 3 分钟有效的一次性配对码。让用户粘贴到扩展的 MCP 连接页即可,复用已有 MCP 和 agent 配置。新配对会替换此前浏览器的连接授权,请在用户要求连接扩展时调用。',
|
|
45
|
-
inputSchema: {}, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
46
|
-
}, () => wrap(() => bridge.beginPairing()))
|
|
47
43
|
server.registerTool('list_platforms', {
|
|
48
44
|
description: '列出扩展实际支持的平台、登录状态、publishModes、对应 tools 及 modeOptions 限制;小红书 article 对应 publish_article,image/video 对应 publish_post。不会返回 Cookie。',
|
|
49
45
|
inputSchema: { force_refresh: z.boolean().optional() }, annotations: { ...readOnly, openWorldHint: true },
|