scanruler-mcp 0.2.0 → 0.4.0
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 +431 -241
- package/bin/scanruler-mcp.js +15 -8
- package/package.json +1 -1
- package/src/commands.json +4435 -2504
- package/src/config.js +38 -6
- package/src/engine.js +69 -0
- package/src/link.js +304 -285
- package/src/main.js +105 -5
- package/src/mcp.js +1155 -290
- package/src/relay.js +218 -217
- package/src/tools.js +207 -14
- package/src/trace.js +55 -0
- package/src/update.js +380 -0
package/src/relay.js
CHANGED
|
@@ -1,217 +1,218 @@
|
|
|
1
|
-
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
-
// One port, any number of copies of this server. An agent may start several —
|
|
3
|
-
// Claude Desktop starts one for its chats and one for its other sessions,
|
|
4
|
-
// and two agents may run side by side — while the ScanRuler tab connects to
|
|
5
|
-
// one port. The copy that gets the port holds the page link (link.js); a
|
|
6
|
-
// copy that finds it taken connects to that one as a peer, with the same
|
|
7
|
-
// token, and sends its calls through it. When the holder stops, a peer takes
|
|
8
|
-
// the port over, and the tab, which keeps trying, connects to it.
|
|
9
|
-
|
|
10
|
-
import { EventEmitter } from 'node:events'
|
|
11
|
-
import WebSocket from 'ws'
|
|
12
|
-
import { decoder } from './framing.js'
|
|
13
|
-
import { Asker, frameOf, MAX_PAYLOAD, PageLink, waitForPage } from './link.js'
|
|
14
|
-
|
|
15
|
-
/** How long a copy waits before trying a port it could not use again. */
|
|
16
|
-
const RETRY_MS = 5_000
|
|
17
|
-
|
|
18
|
-
/** This copy's link through another copy that holds the port. */
|
|
19
|
-
export class PeerLink extends Asker {
|
|
20
|
-
/** @param {{ port: number, token: string, version?: string, log?: (line: string) => void }} options */
|
|
21
|
-
constructor({ port, token, version = '', log = () => {} }) {
|
|
22
|
-
super()
|
|
23
|
-
this.port = port
|
|
24
|
-
this.token = token
|
|
25
|
-
this.version = version
|
|
26
|
-
this.log = log
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Connect to the copy on the port. Resolves `{ ok: true }` once it took
|
|
30
|
-
* this one in, `{ refused }` when it turned it away, `{ error }` when
|
|
31
|
-
* what holds the port is not a scanruler-mcp. */
|
|
32
|
-
connect() {
|
|
33
|
-
return new Promise((resolve) => {
|
|
34
|
-
const ws = new WebSocket(`ws://127.0.0.1:${this.port}/`, { maxPayload: MAX_PAYLOAD, handshakeTimeout: 3_000 })
|
|
35
|
-
let greeted = false
|
|
36
|
-
let accepted = false
|
|
37
|
-
const read = decoder((message) => {
|
|
38
|
-
if (!greeted) {
|
|
39
|
-
greeted = true
|
|
40
|
-
if (message.welcome) {
|
|
41
|
-
accepted = true
|
|
42
|
-
this.socket = ws
|
|
43
|
-
this.#setPage(message.welcome.page ?? null)
|
|
44
|
-
resolve({ ok: true })
|
|
45
|
-
} else resolve({ refused: String(message.refused ?? 'It said no.') })
|
|
46
|
-
return
|
|
47
|
-
}
|
|
48
|
-
if (message.event === 'page') this.#setPage(message.page ?? null)
|
|
49
|
-
else this.answer(message)
|
|
50
|
-
})
|
|
51
|
-
ws.on('open', () => ws.send(JSON.stringify({ peer: { token: this.token, version: this.version } })))
|
|
52
|
-
ws.on('message', (data, isBinary) => {
|
|
53
|
-
try {
|
|
54
|
-
read(frameOf(data, isBinary))
|
|
55
|
-
} catch (e) {
|
|
56
|
-
this.log(`A broken message from the scanruler-mcp on port ${this.port}: ${e.message}`)
|
|
57
|
-
ws.close(1003, 'Broken message')
|
|
58
|
-
}
|
|
59
|
-
})
|
|
60
|
-
ws.on('unexpected-response', (_req, res) => {
|
|
61
|
-
resolve({ error: `HTTP ${res.statusCode}` })
|
|
62
|
-
ws.terminate()
|
|
63
|
-
})
|
|
64
|
-
ws.on('error', (e) => resolve({ error: e.message }))
|
|
65
|
-
ws.on('close', () => {
|
|
66
|
-
resolve({ error: 'It closed the connection.' })
|
|
67
|
-
if (!accepted) return
|
|
68
|
-
const had = this.page !== null
|
|
69
|
-
this.socket = null
|
|
70
|
-
this.page = null
|
|
71
|
-
this.dropPending('The scanruler-mcp this one sent its calls through has stopped.')
|
|
72
|
-
if (had) this.emit('disconnected')
|
|
73
|
-
this.emit('closed')
|
|
74
|
-
})
|
|
75
|
-
})
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
#setPage(page) {
|
|
79
|
-
const was = this.page
|
|
80
|
-
this.page = page
|
|
81
|
-
if (page && !was) this.emit('connected', page)
|
|
82
|
-
else if (!page && was) this.emit('disconnected')
|
|
83
|
-
else if (page) this.emit('
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
* the
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
this.
|
|
107
|
-
this.
|
|
108
|
-
this.
|
|
109
|
-
this.
|
|
110
|
-
this.
|
|
111
|
-
this.
|
|
112
|
-
this.
|
|
113
|
-
this.
|
|
114
|
-
this.holder
|
|
115
|
-
this.
|
|
116
|
-
this.
|
|
117
|
-
this.
|
|
118
|
-
this
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
this.
|
|
122
|
-
this.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
this.
|
|
187
|
-
this.
|
|
188
|
-
this
|
|
189
|
-
peer
|
|
190
|
-
|
|
191
|
-
this.peer
|
|
192
|
-
this.
|
|
193
|
-
this.
|
|
194
|
-
this.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
this.
|
|
204
|
-
this.
|
|
205
|
-
|
|
206
|
-
this.timer
|
|
207
|
-
this.timer.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
this.
|
|
214
|
-
this.
|
|
215
|
-
this.
|
|
216
|
-
|
|
217
|
-
}
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// One port, any number of copies of this server. An agent may start several —
|
|
3
|
+
// Claude Desktop starts one for its chats and one for its other sessions,
|
|
4
|
+
// and two agents may run side by side — while the ScanRuler tab connects to
|
|
5
|
+
// one port. The copy that gets the port holds the page link (link.js); a
|
|
6
|
+
// copy that finds it taken connects to that one as a peer, with the same
|
|
7
|
+
// token, and sends its calls through it. When the holder stops, a peer takes
|
|
8
|
+
// the port over, and the tab, which keeps trying, connects to it.
|
|
9
|
+
|
|
10
|
+
import { EventEmitter } from 'node:events'
|
|
11
|
+
import WebSocket from 'ws'
|
|
12
|
+
import { decoder } from './framing.js'
|
|
13
|
+
import { Asker, frameOf, MAX_PAYLOAD, PageLink, waitForPage } from './link.js'
|
|
14
|
+
|
|
15
|
+
/** How long a copy waits before trying a port it could not use again. */
|
|
16
|
+
const RETRY_MS = 5_000
|
|
17
|
+
|
|
18
|
+
/** This copy's link through another copy that holds the port. */
|
|
19
|
+
export class PeerLink extends Asker {
|
|
20
|
+
/** @param {{ port: number, token: string, version?: string, log?: (line: string) => void }} options */
|
|
21
|
+
constructor({ port, token, version = '', log = () => {} }) {
|
|
22
|
+
super()
|
|
23
|
+
this.port = port
|
|
24
|
+
this.token = token
|
|
25
|
+
this.version = version
|
|
26
|
+
this.log = log
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Connect to the copy on the port. Resolves `{ ok: true }` once it took
|
|
30
|
+
* this one in, `{ refused }` when it turned it away, `{ error }` when
|
|
31
|
+
* what holds the port is not a scanruler-mcp. */
|
|
32
|
+
connect() {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const ws = new WebSocket(`ws://127.0.0.1:${this.port}/`, { maxPayload: MAX_PAYLOAD, handshakeTimeout: 3_000 })
|
|
35
|
+
let greeted = false
|
|
36
|
+
let accepted = false
|
|
37
|
+
const read = decoder((message) => {
|
|
38
|
+
if (!greeted) {
|
|
39
|
+
greeted = true
|
|
40
|
+
if (message.welcome) {
|
|
41
|
+
accepted = true
|
|
42
|
+
this.socket = ws
|
|
43
|
+
this.#setPage(message.welcome.page ?? null)
|
|
44
|
+
resolve({ ok: true })
|
|
45
|
+
} else resolve({ refused: String(message.refused ?? 'It said no.') })
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (message.event === 'page') this.#setPage(message.page ?? null)
|
|
49
|
+
else this.answer(message)
|
|
50
|
+
})
|
|
51
|
+
ws.on('open', () => ws.send(JSON.stringify({ peer: { token: this.token, version: this.version } })))
|
|
52
|
+
ws.on('message', (data, isBinary) => {
|
|
53
|
+
try {
|
|
54
|
+
read(frameOf(data, isBinary))
|
|
55
|
+
} catch (e) {
|
|
56
|
+
this.log(`A broken message from the scanruler-mcp on port ${this.port}: ${e.message}`)
|
|
57
|
+
ws.close(1003, 'Broken message')
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
ws.on('unexpected-response', (_req, res) => {
|
|
61
|
+
resolve({ error: `HTTP ${res.statusCode}` })
|
|
62
|
+
ws.terminate()
|
|
63
|
+
})
|
|
64
|
+
ws.on('error', (e) => resolve({ error: e.message }))
|
|
65
|
+
ws.on('close', () => {
|
|
66
|
+
resolve({ error: 'It closed the connection.' })
|
|
67
|
+
if (!accepted) return
|
|
68
|
+
const had = this.page !== null
|
|
69
|
+
this.socket = null
|
|
70
|
+
this.page = null
|
|
71
|
+
this.dropPending('The scanruler-mcp this one sent its calls through has stopped.')
|
|
72
|
+
if (had) this.emit('disconnected')
|
|
73
|
+
this.emit('closed')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#setPage(page) {
|
|
79
|
+
const was = this.page
|
|
80
|
+
this.page = page
|
|
81
|
+
if (page && !was) this.emit('connected', page)
|
|
82
|
+
else if (!page && was) this.emit('disconnected')
|
|
83
|
+
else if (page && (page.hidden ?? false) !== (was.hidden ?? false)) this.emit('visibility', page.hidden === true)
|
|
84
|
+
else if (page) this.emit('commands', page.commands)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
close() {
|
|
88
|
+
this.socket?.close()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The link a copy of the server uses: the page link while it holds the port,
|
|
94
|
+
* the relay through the copy that does otherwise — the same `connected`,
|
|
95
|
+
* `page`, `run`, `waitForPage` and events either way. `role` says which:
|
|
96
|
+
* 'holder', 'relay', or 'none' while it has neither, with `error` saying why.
|
|
97
|
+
*/
|
|
98
|
+
export class SharedLink extends EventEmitter {
|
|
99
|
+
/**
|
|
100
|
+
* @param {{ http: import('node:http').Server, port: number, token: string,
|
|
101
|
+
* version?: string, origins?: string[], log?: (line: string) => void,
|
|
102
|
+
* retryMs?: number }} options
|
|
103
|
+
*/
|
|
104
|
+
constructor({ http, port, token, version = '', origins = [], log = () => {}, retryMs = RETRY_MS }) {
|
|
105
|
+
super()
|
|
106
|
+
this.http = http
|
|
107
|
+
this.port = port
|
|
108
|
+
this.token = token
|
|
109
|
+
this.version = version
|
|
110
|
+
this.log = log
|
|
111
|
+
this.retryMs = retryMs
|
|
112
|
+
this.role = 'none'
|
|
113
|
+
this.error = null
|
|
114
|
+
this.holder = new PageLink({ token, origins, log, version })
|
|
115
|
+
this.holder.attach(http)
|
|
116
|
+
this.peer = null
|
|
117
|
+
this.timer = null
|
|
118
|
+
this.closed = false
|
|
119
|
+
this.#forward(this.holder, () => this.role === 'holder')
|
|
120
|
+
http.on('listening', () => {
|
|
121
|
+
this.role = 'holder'
|
|
122
|
+
this.error = null
|
|
123
|
+
this.emit('role', 'holder')
|
|
124
|
+
})
|
|
125
|
+
http.on('error', (e) => {
|
|
126
|
+
if (e.code === 'EADDRINUSE') this.#relay()
|
|
127
|
+
else this.#fail(e.message)
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The link in use, or null. */
|
|
132
|
+
get current() {
|
|
133
|
+
return this.role === 'holder' ? this.holder : this.role === 'relay' ? this.peer : null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
get connected() {
|
|
137
|
+
return this.current?.connected ?? false
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
get page() {
|
|
141
|
+
return this.current?.page ?? null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
run(name, input, binaryKey, onProgress, job, deferred = false) {
|
|
145
|
+
const link = this.current
|
|
146
|
+
if (!link) return Promise.reject({ code: 'unavailable', message: 'No ScanRuler page is connected.' })
|
|
147
|
+
return link.run(name, input, binaryKey, onProgress, job, deferred)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
waitForPage(timeoutMs) {
|
|
151
|
+
return waitForPage(this, timeoutMs)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Take the port, or the relay through the copy that has it. */
|
|
155
|
+
start() {
|
|
156
|
+
if (this.closed) return
|
|
157
|
+
clearTimeout(this.timer)
|
|
158
|
+
this.http.listen(this.port, '127.0.0.1')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Pass on a link's page events while `active()` says it is the one in use. */
|
|
162
|
+
#forward(link, active) {
|
|
163
|
+
for (const event of ['connected', 'disconnected', 'commands', 'visibility']) {
|
|
164
|
+
link.on(event, (...args) => {
|
|
165
|
+
if (active()) this.emit(event, ...args)
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async #relay() {
|
|
171
|
+
if (this.closed) return
|
|
172
|
+
const peer = new PeerLink({ port: this.port, token: this.token, version: this.version, log: this.log })
|
|
173
|
+
const outcome = await peer.connect()
|
|
174
|
+
if (this.closed) {
|
|
175
|
+
peer.close()
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
if (!outcome.ok) {
|
|
179
|
+
this.#fail(
|
|
180
|
+
outcome.refused
|
|
181
|
+
? `Port ${this.port} is held by another scanruler-mcp, which turned this one away: ${outcome.refused} Give both the same pairing token (SCANRULER_MCP_TOKEN), or start this one with --port and enter that port in ScanRuler.`
|
|
182
|
+
: `Port ${this.port} is taken by another program. Stop it, or start this one with --port and enter that port in ScanRuler.`,
|
|
183
|
+
)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
this.peer = peer
|
|
187
|
+
this.role = 'relay'
|
|
188
|
+
this.error = null
|
|
189
|
+
this.#forward(peer, () => this.peer === peer)
|
|
190
|
+
peer.once('closed', () => {
|
|
191
|
+
if (this.peer !== peer) return
|
|
192
|
+
this.peer = null
|
|
193
|
+
this.role = 'none'
|
|
194
|
+
this.log(`The scanruler-mcp holding port ${this.port} has stopped; taking the port over.`)
|
|
195
|
+
this.start()
|
|
196
|
+
})
|
|
197
|
+
this.emit('role', 'relay')
|
|
198
|
+
if (peer.connected) this.emit('connected', peer.page)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Say why there is no link, once, and try again later. */
|
|
202
|
+
#fail(message) {
|
|
203
|
+
if (message !== this.error) this.log(message)
|
|
204
|
+
this.role = 'none'
|
|
205
|
+
this.error = message
|
|
206
|
+
clearTimeout(this.timer)
|
|
207
|
+
this.timer = setTimeout(() => this.start(), this.retryMs)
|
|
208
|
+
this.timer.unref()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
close() {
|
|
212
|
+
this.closed = true
|
|
213
|
+
clearTimeout(this.timer)
|
|
214
|
+
this.peer?.close()
|
|
215
|
+
this.holder.close()
|
|
216
|
+
this.http.close()
|
|
217
|
+
}
|
|
218
|
+
}
|