scanruler-mcp 0.3.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 +107 -40
- package/bin/scanruler-mcp.js +15 -8
- package/package.json +1 -1
- package/src/commands.json +4611 -3584
- package/src/config.js +38 -6
- package/src/link.js +304 -293
- package/src/main.js +44 -11
- package/src/mcp.js +1155 -615
- package/src/relay.js +218 -218
- package/src/tools.js +192 -17
- package/src/update.js +380 -0
package/src/link.js
CHANGED
|
@@ -1,293 +1,304 @@
|
|
|
1
|
-
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
-
// The link to the ScanRuler page: a WebSocket server on 127.0.0.1 that the
|
|
3
|
-
// page connects to when its "Local agent connection" is on. A page is
|
|
4
|
-
// accepted from an allowed origin, with the pairing token, one at a time.
|
|
5
|
-
// Once in, the page says which commands it has; this side sends it commands
|
|
6
|
-
// to run and gets their results back — see framing.js for the frames.
|
|
7
|
-
//
|
|
8
|
-
// Other copies of this server are let in beside the page, with the same
|
|
9
|
-
// token: an agent may start more than one — Claude Desktop starts one for its
|
|
10
|
-
// chats and one for its other sessions — and only one can hold the port.
|
|
11
|
-
// The others send their calls through this one (relay.js); it passes them to
|
|
12
|
-
// the page and the answers back, and tells them when the page comes and goes.
|
|
13
|
-
|
|
14
|
-
import { EventEmitter } from 'node:events'
|
|
15
|
-
import { WebSocketServer } from 'ws'
|
|
16
|
-
import { decoder, encode } from './framing.js'
|
|
17
|
-
|
|
18
|
-
/** The hosted app. Loopback pages — a dev server, `--serve` — are allowed
|
|
19
|
-
* on any port; anything else takes `--allow-origin`. */
|
|
20
|
-
const HOSTED_ORIGINS = ['https://scanruler.com', 'https://www.scanruler.com']
|
|
21
|
-
|
|
22
|
-
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]'])
|
|
23
|
-
|
|
24
|
-
/** Whether a page from `origin` may connect. A connection without an Origin
|
|
25
|
-
* is not from a browser page; the token alone decides for it. */
|
|
26
|
-
export function originAllowed(origin, extra = []) {
|
|
27
|
-
if (!origin) return true
|
|
28
|
-
if (HOSTED_ORIGINS.includes(origin) || extra.includes(origin)) return true
|
|
29
|
-
try {
|
|
30
|
-
const url = new URL(origin)
|
|
31
|
-
return (url.protocol === 'http:' || url.protocol === 'https:') && LOOPBACK.has(url.hostname)
|
|
32
|
-
} catch {
|
|
33
|
-
return false
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Close codes the page reads, to say why it was turned away. */
|
|
38
|
-
export const CLOSE = { badToken: 4001, origin: 4003, taken: 4009, noHello: 4008 }
|
|
39
|
-
|
|
40
|
-
/** Scans run to hundreds of megabytes. */
|
|
41
|
-
export const MAX_PAYLOAD = 1024 ** 3
|
|
42
|
-
const HEARTBEAT_MS = 15_000
|
|
43
|
-
const HELLO_MS = 10_000
|
|
44
|
-
|
|
45
|
-
/** Where a result carries a file's bytes, if it does. */
|
|
46
|
-
const resultBinary = (result) => (result?.file?.bytes instanceof Uint8Array ? 'result.file.bytes' : undefined)
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
export const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
})
|
|
194
|
-
ws.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
ws
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
})
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
this
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
this
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// The link to the ScanRuler page: a WebSocket server on 127.0.0.1 that the
|
|
3
|
+
// page connects to when its "Local agent connection" is on. A page is
|
|
4
|
+
// accepted from an allowed origin, with the pairing token, one at a time.
|
|
5
|
+
// Once in, the page says which commands it has; this side sends it commands
|
|
6
|
+
// to run and gets their results back — see framing.js for the frames.
|
|
7
|
+
//
|
|
8
|
+
// Other copies of this server are let in beside the page, with the same
|
|
9
|
+
// token: an agent may start more than one — Claude Desktop starts one for its
|
|
10
|
+
// chats and one for its other sessions — and only one can hold the port.
|
|
11
|
+
// The others send their calls through this one (relay.js); it passes them to
|
|
12
|
+
// the page and the answers back, and tells them when the page comes and goes.
|
|
13
|
+
|
|
14
|
+
import { EventEmitter } from 'node:events'
|
|
15
|
+
import { WebSocketServer } from 'ws'
|
|
16
|
+
import { decoder, encode } from './framing.js'
|
|
17
|
+
|
|
18
|
+
/** The hosted app. Loopback pages — a dev server, `--serve` — are allowed
|
|
19
|
+
* on any port; anything else takes `--allow-origin`. */
|
|
20
|
+
const HOSTED_ORIGINS = ['https://scanruler.com', 'https://www.scanruler.com']
|
|
21
|
+
|
|
22
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]'])
|
|
23
|
+
|
|
24
|
+
/** Whether a page from `origin` may connect. A connection without an Origin
|
|
25
|
+
* is not from a browser page; the token alone decides for it. */
|
|
26
|
+
export function originAllowed(origin, extra = []) {
|
|
27
|
+
if (!origin) return true
|
|
28
|
+
if (HOSTED_ORIGINS.includes(origin) || extra.includes(origin)) return true
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(origin)
|
|
31
|
+
return (url.protocol === 'http:' || url.protocol === 'https:') && LOOPBACK.has(url.hostname)
|
|
32
|
+
} catch {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Close codes the page reads, to say why it was turned away. */
|
|
38
|
+
export const CLOSE = { badToken: 4001, origin: 4003, taken: 4009, noHello: 4008 }
|
|
39
|
+
|
|
40
|
+
/** Scans run to hundreds of megabytes. */
|
|
41
|
+
export const MAX_PAYLOAD = 1024 ** 3
|
|
42
|
+
const HEARTBEAT_MS = 15_000
|
|
43
|
+
const HELLO_MS = 10_000
|
|
44
|
+
|
|
45
|
+
/** Where a result carries a file's bytes, if it does. */
|
|
46
|
+
const resultBinary = (result) => (result?.file?.bytes instanceof Uint8Array ? 'result.file.bytes' : undefined)
|
|
47
|
+
|
|
48
|
+
/** What a call dropped unanswered says after why. */
|
|
49
|
+
export const MAY_HAVE_LANDED =
|
|
50
|
+
'If it was a change, it may still have been made on the page: read session_state (history.undo names the last step taken) before running it again, rather than retry it blindly.'
|
|
51
|
+
|
|
52
|
+
/** A socket's frame, as the decoder takes it. */
|
|
53
|
+
export const frameOf = (data, isBinary) => (isBinary ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) : data.toString('utf8'))
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Asking the far end of a socket something and hearing the answer, by id —
|
|
57
|
+
* what the page link and a relay to another copy share. `socket` is the
|
|
58
|
+
* socket asked; `page` what the page said about itself, null while there is
|
|
59
|
+
* none.
|
|
60
|
+
*/
|
|
61
|
+
export class Asker extends EventEmitter {
|
|
62
|
+
constructor() {
|
|
63
|
+
super()
|
|
64
|
+
this.socket = null
|
|
65
|
+
this.page = null
|
|
66
|
+
this.pending = new Map()
|
|
67
|
+
this.nextId = 1
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get connected() {
|
|
71
|
+
return this.page !== null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Take an answer, or a progress line, for something asked. */
|
|
75
|
+
answer(message) {
|
|
76
|
+
if (message.event === 'progress') {
|
|
77
|
+
this.pending.get(message.id)?.onProgress?.(String(message.text ?? ''))
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const entry = this.pending.get(message.id)
|
|
81
|
+
if (!entry) return
|
|
82
|
+
this.pending.delete(message.id)
|
|
83
|
+
if (message.error) entry.reject(message.error)
|
|
84
|
+
else entry.resolve(message.result)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Turn down everything still waiting for an answer — saying, besides
|
|
88
|
+
* why, that a change may have been made all the same: the page may have
|
|
89
|
+
* run it and gone before its answer came back. */
|
|
90
|
+
dropPending(message) {
|
|
91
|
+
for (const { reject } of this.pending.values()) reject({ code: 'unavailable', message: `${message} ${MAY_HAVE_LANDED}` })
|
|
92
|
+
this.pending.clear()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Ask the page something; resolves with its result, rejects with its
|
|
96
|
+
* `{ code, message }`. `binaryPath` names bytes in the request that go as
|
|
97
|
+
* a binary frame; `onProgress` hears the page's progress lines. */
|
|
98
|
+
request(message, binaryPath, onProgress) {
|
|
99
|
+
const ws = this.socket
|
|
100
|
+
if (!ws || ws.readyState !== ws.OPEN) return Promise.reject({ code: 'unavailable', message: 'No ScanRuler page is connected.' })
|
|
101
|
+
const id = this.nextId++
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
this.pending.set(id, { resolve, reject, onProgress })
|
|
104
|
+
try {
|
|
105
|
+
for (const frame of encode({ id, ...message }, binaryPath)) ws.send(frame)
|
|
106
|
+
} catch (e) {
|
|
107
|
+
this.pending.delete(id)
|
|
108
|
+
reject({ code: 'internal', message: e.message })
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Run a command on the page. `binaryKey` is the input field holding a
|
|
114
|
+
* file's bytes, if one does; `job` the job the run is part of, which the
|
|
115
|
+
* page keeps its outcome under (session.recent); `deferred`, that more
|
|
116
|
+
* changes follow straight after it, which build once after the last. */
|
|
117
|
+
run(name, input, binaryKey, onProgress, job, deferred = false) {
|
|
118
|
+
return this.request({ method: 'run', name, input, ...(job ? { job } : {}), ...(deferred ? { deferred: true } : {}) }, binaryKey ? `input.${binaryKey}` : undefined, onProgress)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Resolves true once a page is connected, false after `timeoutMs`. */
|
|
122
|
+
waitForPage(timeoutMs) {
|
|
123
|
+
return waitForPage(this, timeoutMs)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Resolves true once `link` has a page, false after `timeoutMs`. */
|
|
128
|
+
export function waitForPage(link, timeoutMs) {
|
|
129
|
+
if (link.connected) return Promise.resolve(true)
|
|
130
|
+
return new Promise((resolve) => {
|
|
131
|
+
const done = (value) => {
|
|
132
|
+
clearTimeout(timer)
|
|
133
|
+
link.off('connected', onConnected)
|
|
134
|
+
resolve(value)
|
|
135
|
+
}
|
|
136
|
+
const onConnected = () => done(true)
|
|
137
|
+
const timer = setTimeout(() => done(false), timeoutMs)
|
|
138
|
+
link.on('connected', onConnected)
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export class PageLink extends Asker {
|
|
143
|
+
/** @param {{ token: string, origins?: string[], log?: (line: string) => void, version?: string }} options */
|
|
144
|
+
constructor({ token, origins = [], log = () => {}, version = '' }) {
|
|
145
|
+
super()
|
|
146
|
+
this.token = token
|
|
147
|
+
/** This server's version, in the page's welcome — the page tells an out
|
|
148
|
+
* of date server by it (before 0.4.0 the welcome had none). */
|
|
149
|
+
this.version = version
|
|
150
|
+
this.origins = origins
|
|
151
|
+
this.log = log
|
|
152
|
+
/** The other copies relaying through this one. */
|
|
153
|
+
this.peers = new Set()
|
|
154
|
+
this.wss = null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Take WebSocket upgrades on an HTTP server. */
|
|
158
|
+
attach(server) {
|
|
159
|
+
this.wss = new WebSocketServer({
|
|
160
|
+
server,
|
|
161
|
+
maxPayload: MAX_PAYLOAD,
|
|
162
|
+
verifyClient: ({ origin }, done) => {
|
|
163
|
+
if (originAllowed(origin, this.origins)) done(true)
|
|
164
|
+
else {
|
|
165
|
+
this.log(`Refused a page from ${origin}: not an allowed origin (see --allow-origin).`)
|
|
166
|
+
done(false, 403, 'Origin not allowed')
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
// ws hands the HTTP server's errors on to this one too — a port another
|
|
171
|
+
// copy holds among them. The HTTP server's own listener deals with them;
|
|
172
|
+
// unheard here, Node would end the process over it.
|
|
173
|
+
this.wss.on('error', () => {})
|
|
174
|
+
this.wss.on('connection', (ws, req) => this.#greet(ws, req.headers.origin))
|
|
175
|
+
const beat = setInterval(() => {
|
|
176
|
+
for (const ws of this.wss.clients) {
|
|
177
|
+
if (ws.alive === false) {
|
|
178
|
+
ws.terminate()
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
ws.alive = false
|
|
182
|
+
ws.ping()
|
|
183
|
+
}
|
|
184
|
+
}, HEARTBEAT_MS)
|
|
185
|
+
beat.unref()
|
|
186
|
+
this.wss.on('close', () => clearInterval(beat))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#greet(ws, origin) {
|
|
190
|
+
ws.alive = true
|
|
191
|
+
ws.on('pong', () => {
|
|
192
|
+
ws.alive = true
|
|
193
|
+
})
|
|
194
|
+
const timer = setTimeout(() => ws.close(CLOSE.noHello, 'No hello'), HELLO_MS)
|
|
195
|
+
let accepted = false
|
|
196
|
+
const read = decoder((message) => {
|
|
197
|
+
if (!accepted) {
|
|
198
|
+
clearTimeout(timer)
|
|
199
|
+
accepted = this.#hello(ws, message, origin)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (this.peers.has(ws)) this.#relay(ws, message)
|
|
203
|
+
else this.#receive(message)
|
|
204
|
+
})
|
|
205
|
+
ws.on('message', (data, isBinary) => {
|
|
206
|
+
try {
|
|
207
|
+
read(frameOf(data, isBinary))
|
|
208
|
+
} catch (e) {
|
|
209
|
+
this.log(`A broken message ${this.peers.has(ws) ? 'from another scanruler-mcp' : 'from the page'}: ${e.message}`)
|
|
210
|
+
ws.close(1003, 'Broken message')
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
ws.on('close', () => {
|
|
214
|
+
clearTimeout(timer)
|
|
215
|
+
if (this.peers.delete(ws)) return
|
|
216
|
+
if (this.socket !== ws) return
|
|
217
|
+
this.socket = null
|
|
218
|
+
this.page = null
|
|
219
|
+
this.dropPending('The ScanRuler page closed the connection before it answered.')
|
|
220
|
+
this.log('The ScanRuler page disconnected.')
|
|
221
|
+
this.#tellPeers()
|
|
222
|
+
this.emit('disconnected')
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The first message: the page's — who it is, its token, its commands — or
|
|
227
|
+
* another copy's. True when it is taken in. */
|
|
228
|
+
#hello(ws, message, origin) {
|
|
229
|
+
const refuse = (code, reason) => {
|
|
230
|
+
ws.send(JSON.stringify({ refused: reason }))
|
|
231
|
+
ws.close(code, reason.slice(0, 100))
|
|
232
|
+
this.log(`Refused ${message.peer ? 'another scanruler-mcp' : 'a page'}: ${reason}`)
|
|
233
|
+
return false
|
|
234
|
+
}
|
|
235
|
+
if (message.peer && typeof message.peer === 'object') {
|
|
236
|
+
if (message.peer.token !== this.token) return refuse(CLOSE.badToken, 'Its pairing token is not this one’s.')
|
|
237
|
+
this.peers.add(ws)
|
|
238
|
+
ws.send(JSON.stringify({ welcome: { server: 'scanruler-mcp', page: this.page } }))
|
|
239
|
+
this.log(`Another scanruler-mcp${message.peer.version ? ` ${message.peer.version}` : ''} sends its calls through this one.`)
|
|
240
|
+
return true
|
|
241
|
+
}
|
|
242
|
+
const hello = message.hello
|
|
243
|
+
if (!hello || typeof hello !== 'object') return refuse(CLOSE.noHello, 'The page did not say hello.')
|
|
244
|
+
if (hello.token !== this.token) {
|
|
245
|
+
return refuse(CLOSE.badToken, 'The pairing token does not match — copy it again from `npx -y scanruler-mcp@latest --pair` into ScanRuler’s Settings.')
|
|
246
|
+
}
|
|
247
|
+
if (this.socket && this.socket.readyState === this.socket.OPEN) {
|
|
248
|
+
return refuse(CLOSE.taken, 'Another ScanRuler tab is connected to this agent already — close the connection there first.')
|
|
249
|
+
}
|
|
250
|
+
this.socket = ws
|
|
251
|
+
const { token: _token, ...page } = hello
|
|
252
|
+
this.page = { ...page, origin: origin ?? null, commands: Array.isArray(hello.commands) ? hello.commands : [] }
|
|
253
|
+
ws.send(JSON.stringify({ welcome: { server: 'scanruler-mcp', ...(this.version ? { version: this.version } : {}) } }))
|
|
254
|
+
this.log(`ScanRuler ${page.version ?? ''} connected${origin ? ` from ${origin}` : ''}, with ${this.page.commands.length} commands.`)
|
|
255
|
+
this.#tellPeers()
|
|
256
|
+
this.emit('connected', this.page)
|
|
257
|
+
return true
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
#receive(message) {
|
|
261
|
+
// The tab hidden or shown again — in the background, its window
|
|
262
|
+
// covered, the screen locked — for the status to say.
|
|
263
|
+
if (message.event === 'visibility') {
|
|
264
|
+
this.page = { ...this.page, hidden: message.hidden === true }
|
|
265
|
+
this.#tellPeers()
|
|
266
|
+
this.emit('visibility', this.page.hidden)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
if (message.event === 'commands' && Array.isArray(message.commands)) {
|
|
270
|
+
this.page = { ...this.page, commands: message.commands }
|
|
271
|
+
this.#tellPeers()
|
|
272
|
+
this.emit('commands', message.commands)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
this.answer(message)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Tell the other copies what page there is now. */
|
|
279
|
+
#tellPeers() {
|
|
280
|
+
const frame = JSON.stringify({ event: 'page', page: this.page })
|
|
281
|
+
for (const ws of this.peers) if (ws.readyState === ws.OPEN) ws.send(frame)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Pass another copy's request to the page, and the page's answer back. */
|
|
285
|
+
#relay(ws, { id, ...message }) {
|
|
286
|
+
const send = (reply, binaryPath) => {
|
|
287
|
+
if (ws.readyState !== ws.OPEN) return
|
|
288
|
+
for (const frame of encode({ id, ...reply }, binaryPath)) ws.send(frame)
|
|
289
|
+
}
|
|
290
|
+
if (message.method !== 'run' && message.method !== 'list') {
|
|
291
|
+
send({ error: { code: 'invalid_input', message: `No such request: ${message.method}.` } })
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
const key = Object.keys(message.input ?? {}).find((k) => message.input[k] instanceof Uint8Array)
|
|
295
|
+
this.request(message, key ? `input.${key}` : undefined, (text) => send({ event: 'progress', text }))
|
|
296
|
+
.then((result) => send({ result }, resultBinary(result)))
|
|
297
|
+
.catch((e) => send({ error: { code: e?.code ?? 'failed', message: e?.message ?? String(e) } }))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
close() {
|
|
301
|
+
for (const ws of this.wss?.clients ?? []) ws.terminate()
|
|
302
|
+
this.wss?.close()
|
|
303
|
+
}
|
|
304
|
+
}
|