writedock-mcp 0.4.4 → 0.4.6
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 +22 -20
- package/extension/LICENSE-MULTIPOST.txt +201 -0
- package/extension/LICENSE.txt +674 -0
- package/extension/NOTICE.txt +13 -0
- package/extension/assets/editor-DWoK50xk.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-DbQynGAJ.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 +287 -69
- 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 +40 -11
- package/source/src/extension-id.ts +2 -0
- package/source/src/extension-update.ts +82 -0
- package/source/src/index.ts +3 -0
- package/source/src/release-info.ts +3 -0
- package/source/src/server.ts +3 -3
- package/source/src/setup.ts +88 -0
|
@@ -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
|
@@ -6,6 +6,8 @@ import { createCipheriv, createHash, createHmac, randomBytes, randomUUID, timing
|
|
|
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)
|
|
@@ -59,10 +61,10 @@ export class LocalBridge {
|
|
|
59
61
|
server.on('upgrade', (req, socket, head) => {
|
|
60
62
|
const origin = req.headers.origin
|
|
61
63
|
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)) {
|
|
64
|
+
if (!this.validHost(req.headers.host) || !['/extension', '/pair', '/connect'].includes(req.url || '') || (origin && !extensionOrigin) || (req.url === '/pair' && !extensionOrigin) || (req.url === '/connect' && origin !== EXTENSION_ORIGIN)) {
|
|
63
65
|
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); socket.destroy(); return
|
|
64
66
|
}
|
|
65
|
-
sockets.handleUpgrade(req, socket, head, ws => { if (req.url === '/pair') this.acceptPairing(ws); else this.accept(ws) })
|
|
67
|
+
sockets.handleUpgrade(req, socket, head, ws => { if (req.url === '/pair') this.acceptPairing(ws); else if (req.url === '/connect') void this.acceptConnect(ws); else this.accept(ws) })
|
|
66
68
|
})
|
|
67
69
|
await new Promise<void>((resolve, reject) => {
|
|
68
70
|
server.once('error', reject)
|
|
@@ -90,6 +92,28 @@ export class LocalBridge {
|
|
|
90
92
|
return { pairing_code: code, expires_at: new Date(expiresAt).toISOString(), expires_in_seconds: 180, instructions: '将此一次性配对码粘贴到扩展的「MCP 连接 → 输入一次性配对码」。有效期 3 分钟,使用一次后失效。无需重新安装 MCP 或修改 agent 配置。确认用户要连接当前扩展后再生成。' }
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/** Replaces the extension credential and drops whatever was using the old one. */
|
|
96
|
+
private async replaceExtensionToken(reason: string) {
|
|
97
|
+
const token = randomBytes(32).toString('hex')
|
|
98
|
+
await this.options.credentials?.setExtensionToken(token)
|
|
99
|
+
this.extensionToken = token
|
|
100
|
+
const previousClient = this.client
|
|
101
|
+
this.client = undefined; this.extensionId = undefined
|
|
102
|
+
for (const value of this.pending.values()) { clearTimeout(value.timer); value.reject(new Error(reason)) }
|
|
103
|
+
this.pending.clear()
|
|
104
|
+
previousClient?.close(1000, 'Extension pairing replaced')
|
|
105
|
+
return token
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One-click connection: only this extension's fixed origin can reach here, and the user has just clicked 连接. */
|
|
109
|
+
private async acceptConnect(ws: WebSocket) {
|
|
110
|
+
try {
|
|
111
|
+
const token = await this.replaceExtensionToken('扩展连接已更换;请查询原同步任务,不要自动重新提交。')
|
|
112
|
+
ws.send(JSON.stringify({ type: 'connect_result', token, port: this.port, protocol: 'writedock/1' }))
|
|
113
|
+
ws.close(1000)
|
|
114
|
+
} catch { ws.close(1011, 'Connect failed') }
|
|
115
|
+
}
|
|
116
|
+
|
|
93
117
|
private acceptPairing(ws: WebSocket) {
|
|
94
118
|
const pairing = this.pairing
|
|
95
119
|
if (!pairing || Date.now() > pairing.expiresAt || pairing.attempts >= 8) { ws.close(1008, 'Pairing code expired'); return }
|
|
@@ -107,15 +131,8 @@ export class LocalBridge {
|
|
|
107
131
|
const message = JSON.parse(body)
|
|
108
132
|
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
133
|
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
134
|
// A deliberate new pairing retires the old extension credential, not the MCP identity.
|
|
114
|
-
const
|
|
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')
|
|
135
|
+
const token = await this.replaceExtensionToken('扩展配对已更换;请查询原同步任务,不要自动重新提交。')
|
|
119
136
|
const key = createHmac('sha256', pairing.code).update(`writedock-pairing-key:${nonce}`).digest()
|
|
120
137
|
const iv = randomBytes(12)
|
|
121
138
|
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
|
@@ -150,6 +167,13 @@ export class LocalBridge {
|
|
|
150
167
|
return
|
|
151
168
|
}
|
|
152
169
|
if (message.type === 'pong') return
|
|
170
|
+
if (message.type === 'update_extension') {
|
|
171
|
+
void updateInstalledExtension().then(
|
|
172
|
+
result => ws.send(JSON.stringify({ type: 'update_extension_result', ok: true, version: result.version })),
|
|
173
|
+
error => ws.send(JSON.stringify({ type: 'update_extension_result', ok: false, error: messageOf(error) })),
|
|
174
|
+
)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
153
177
|
const pending = this.pending.get(message.id)
|
|
154
178
|
if (!pending) return
|
|
155
179
|
this.pending.delete(message.id); clearTimeout(pending.timer)
|
|
@@ -179,6 +203,11 @@ export class LocalBridge {
|
|
|
179
203
|
if (responseNonce) res.setHeader('X-Writedock-Proof', proof(this.options.token, `http-response:${digest(body)}`, responseNonce))
|
|
180
204
|
res.writeHead(status); res.end(body)
|
|
181
205
|
}
|
|
206
|
+
// Lets the extension find this service before it is connected. Reveals only that it exists and its version.
|
|
207
|
+
// Chrome omits Origin on GETs from an extension with host permission; web pages always send theirs.
|
|
208
|
+
if (req.url === '/hello' && req.method === 'GET' && this.validHost(req.headers.host) && (!req.headers.origin || req.headers.origin === EXTENSION_ORIGIN)) {
|
|
209
|
+
send(200, { service: 'writedock-mcp', protocol: 'writedock/1', version: mcpReleaseInfo().mcp_version }); return
|
|
210
|
+
}
|
|
182
211
|
const assetPath = /^\/assets\/([a-f0-9-]{36})$/.exec(req.url || '')
|
|
183
212
|
if (assetPath && this.validHost(req.headers.host) && (!req.headers.origin || /^chrome-extension:\/\/[a-p]{32}$/.test(req.headers.origin))) {
|
|
184
213
|
await this.media.serve(req, res, assetPath[1]); return
|
|
@@ -270,7 +299,7 @@ export class LocalBridge {
|
|
|
270
299
|
}
|
|
271
300
|
|
|
272
301
|
private async requestConnected(method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
273
|
-
if (this.client?.readyState !== WebSocket.OPEN) throw new Error('
|
|
302
|
+
if (this.client?.readyState !== WebSocket.OPEN) throw new Error('扩展未连接。请让用户打开浏览器的 WriteDock 扩展,在「连接」页点“连接”。无需重装 MCP。')
|
|
274
303
|
const id = randomUUID()
|
|
275
304
|
return new Promise((resolve, reject) => {
|
|
276
305
|
const timer = setTimeout(() => {
|
|
@@ -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/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,10 +38,10 @@ 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
43
|
server.registerTool('begin_pairing', {
|
|
44
|
-
description: '
|
|
44
|
+
description: '通常不需要:未连接时,让用户打开 WriteDock 扩展的「连接」页点“连接”即可。只有用户使用旧版扩展时才生成 3 分钟有效的一次性配对码。',
|
|
45
45
|
inputSchema: {}, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
46
46
|
}, () => wrap(() => bridge.beginPairing()))
|
|
47
47
|
server.registerTool('list_platforms', {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { bundledExtension, defaultExtensionDirectory, findInstalledExtension, installExtensionFiles } from './extension-update'
|
|
6
|
+
|
|
7
|
+
const COMMAND = 'npx'
|
|
8
|
+
const ARGS = ['-y', '--prefer-online', 'writedock-mcp@latest']
|
|
9
|
+
|
|
10
|
+
async function readIfPresent(path: string) {
|
|
11
|
+
try { return await readFile(path, 'utf8') }
|
|
12
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function save(path: string, content: string, previous: string | undefined) {
|
|
16
|
+
await mkdir(dirname(path), { recursive: true })
|
|
17
|
+
if (previous !== undefined) await copyFile(path, `${path}.writedock-backup`)
|
|
18
|
+
await writeFile(path, content)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Keeps the user's other settings; an existing WriteDock entry stays, because it may hold a custom port or data directory. */
|
|
22
|
+
export async function configureCodex(path: string): Promise<'added' | 'present'> {
|
|
23
|
+
const previous = await readIfPresent(path)
|
|
24
|
+
if (previous !== undefined && /^\s*\[mcp_servers\.writedock\]/m.test(previous)) return 'present'
|
|
25
|
+
const entry = `[mcp_servers.writedock]\ncommand = "${COMMAND}"\nargs = ${JSON.stringify(ARGS)}\nstartup_timeout_sec = 120\n`
|
|
26
|
+
await save(path, previous ? `${previous.replace(/\s*$/, '')}\n\n${entry}` : entry, previous)
|
|
27
|
+
return 'added'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function configureJsonClient(path: string): Promise<'added' | 'present'> {
|
|
31
|
+
const previous = await readIfPresent(path)
|
|
32
|
+
const config = previous?.trim() ? JSON.parse(previous) as { mcpServers?: Record<string, unknown> } : {}
|
|
33
|
+
if (config.mcpServers?.writedock) return 'present'
|
|
34
|
+
config.mcpServers = { ...config.mcpServers, writedock: { command: COMMAND, args: ARGS } }
|
|
35
|
+
await save(path, `${JSON.stringify(config, null, 2)}\n`, previous)
|
|
36
|
+
return 'added'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const exists = async (path: string) => (await readIfPresent(path)) !== undefined
|
|
40
|
+
|
|
41
|
+
/** Best effort: shows the folder so the user can pick it, or drag it onto the extensions page. */
|
|
42
|
+
function revealFolder(path: string) {
|
|
43
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open'
|
|
44
|
+
try { const child = spawn(command, [path], { detached: true, stdio: 'ignore' }); child.on('error', () => {}); child.unref() } catch { /* The path is printed as well. */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Only clients already on this computer are configured, so setup never creates settings for an app the user does not have. */
|
|
48
|
+
export async function runSetup(home = homedir(), log: (line: string) => void = console.log) {
|
|
49
|
+
const clients: { name: string; path: string; detect: string; configure: (path: string) => Promise<'added' | 'present'> }[] = [
|
|
50
|
+
{ name: 'Codex', path: join(home, '.codex/config.toml'), detect: join(home, '.codex/config.toml'), configure: configureCodex },
|
|
51
|
+
{ name: 'Claude Code', path: join(home, '.claude.json'), detect: join(home, '.claude.json'), configure: configureJsonClient },
|
|
52
|
+
{ name: 'Cursor', path: join(home, '.cursor/mcp.json'), detect: join(home, '.cursor/mcp.json'), configure: configureJsonClient },
|
|
53
|
+
{ name: 'Claude Desktop', path: join(home, 'Library/Application Support/Claude/claude_desktop_config.json'), detect: join(home, 'Library/Application Support/Claude/claude_desktop_config.json'), configure: configureJsonClient },
|
|
54
|
+
]
|
|
55
|
+
let found = 0
|
|
56
|
+
for (const client of clients) {
|
|
57
|
+
if (!await exists(client.detect)) continue
|
|
58
|
+
found++
|
|
59
|
+
try {
|
|
60
|
+
const state = await client.configure(client.path)
|
|
61
|
+
log(state === 'added' ? `✓ ${client.name}:已添加 WriteDock(原配置备份为 .writedock-backup)` : `✓ ${client.name}:已有 WriteDock 配置,未改动`)
|
|
62
|
+
} catch (error) { log(`✗ ${client.name}:配置失败(${error instanceof Error ? error.message : String(error)}),原文件未改动`) }
|
|
63
|
+
}
|
|
64
|
+
if (!found) {
|
|
65
|
+
log('没有找到已知的 agent 客户端。请在你的客户端里添加这个 MCP 服务:')
|
|
66
|
+
log(JSON.stringify({ mcpServers: { writedock: { command: COMMAND, args: ARGS } } }, null, 2))
|
|
67
|
+
}
|
|
68
|
+
// Keep whichever folder the browser already loads, so a repeat setup does not ask for another manual load.
|
|
69
|
+
const bundle = bundledExtension()
|
|
70
|
+
const loaded = bundle ? await findInstalledExtension().catch(() => undefined) : undefined
|
|
71
|
+
let extensionFolder: string | undefined
|
|
72
|
+
if (bundle) {
|
|
73
|
+
try { extensionFolder = (await installExtensionFiles(bundle.directory, loaded || defaultExtensionDirectory(home))).directory }
|
|
74
|
+
catch (error) { log(`✗ 扩展:安装失败(${error instanceof Error ? error.message : String(error)})`) }
|
|
75
|
+
}
|
|
76
|
+
log('')
|
|
77
|
+
log('接下来:')
|
|
78
|
+
log('1. 重启你的 agent 客户端,让它加载 WriteDock。')
|
|
79
|
+
if (extensionFolder && !loaded) {
|
|
80
|
+
log('2. 在 Chrome 或 Edge 打开 chrome://extensions,打开右上角的“开发者模式”,点“加载已解压的扩展程序”,选择这个文件夹:')
|
|
81
|
+
log(` ${extensionFolder}`)
|
|
82
|
+
revealFolder(extensionFolder)
|
|
83
|
+
log('3. 打开 WriteDock 扩展,点“连接”。')
|
|
84
|
+
} else if (extensionFolder) {
|
|
85
|
+
log(`2. 扩展已更新。在 chrome://extensions 点一次 WriteDock 的刷新图标,然后点“连接”。`)
|
|
86
|
+
} else log('2. 打开浏览器的 WriteDock 扩展,点“连接”。')
|
|
87
|
+
return found
|
|
88
|
+
}
|