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/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('commands', page.commands)
84
- }
85
-
86
- close() {
87
- this.socket?.close()
88
- }
89
- }
90
-
91
- /**
92
- * The link a copy of the server uses: the page link while it holds the port,
93
- * the relay through the copy that does otherwise — the same `connected`,
94
- * `page`, `run`, `waitForPage` and events either way. `role` says which:
95
- * 'holder', 'relay', or 'none' while it has neither, with `error` saying why.
96
- */
97
- export class SharedLink extends EventEmitter {
98
- /**
99
- * @param {{ http: import('node:http').Server, port: number, token: string,
100
- * version?: string, origins?: string[], log?: (line: string) => void,
101
- * retryMs?: number }} options
102
- */
103
- constructor({ http, port, token, version = '', origins = [], log = () => {}, retryMs = RETRY_MS }) {
104
- super()
105
- this.http = http
106
- this.port = port
107
- this.token = token
108
- this.version = version
109
- this.log = log
110
- this.retryMs = retryMs
111
- this.role = 'none'
112
- this.error = null
113
- this.holder = new PageLink({ token, origins, log })
114
- this.holder.attach(http)
115
- this.peer = null
116
- this.timer = null
117
- this.closed = false
118
- this.#forward(this.holder, () => this.role === 'holder')
119
- http.on('listening', () => {
120
- this.role = 'holder'
121
- this.error = null
122
- this.emit('role', 'holder')
123
- })
124
- http.on('error', (e) => {
125
- if (e.code === 'EADDRINUSE') this.#relay()
126
- else this.#fail(e.message)
127
- })
128
- }
129
-
130
- /** The link in use, or null. */
131
- get current() {
132
- return this.role === 'holder' ? this.holder : this.role === 'relay' ? this.peer : null
133
- }
134
-
135
- get connected() {
136
- return this.current?.connected ?? false
137
- }
138
-
139
- get page() {
140
- return this.current?.page ?? null
141
- }
142
-
143
- run(name, input, binaryKey, onProgress) {
144
- const link = this.current
145
- if (!link) return Promise.reject({ code: 'unavailable', message: 'No ScanRuler page is connected.' })
146
- return link.run(name, input, binaryKey, onProgress)
147
- }
148
-
149
- waitForPage(timeoutMs) {
150
- return waitForPage(this, timeoutMs)
151
- }
152
-
153
- /** Take the port, or the relay through the copy that has it. */
154
- start() {
155
- if (this.closed) return
156
- clearTimeout(this.timer)
157
- this.http.listen(this.port, '127.0.0.1')
158
- }
159
-
160
- /** Pass on a link's page events while `active()` says it is the one in use. */
161
- #forward(link, active) {
162
- for (const event of ['connected', 'disconnected', 'commands']) {
163
- link.on(event, (...args) => {
164
- if (active()) this.emit(event, ...args)
165
- })
166
- }
167
- }
168
-
169
- async #relay() {
170
- if (this.closed) return
171
- const peer = new PeerLink({ port: this.port, token: this.token, version: this.version, log: this.log })
172
- const outcome = await peer.connect()
173
- if (this.closed) {
174
- peer.close()
175
- return
176
- }
177
- if (!outcome.ok) {
178
- this.#fail(
179
- outcome.refused
180
- ? `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.`
181
- : `Port ${this.port} is taken by another program. Stop it, or start this one with --port and enter that port in ScanRuler.`,
182
- )
183
- return
184
- }
185
- this.peer = peer
186
- this.role = 'relay'
187
- this.error = null
188
- this.#forward(peer, () => this.peer === peer)
189
- peer.once('closed', () => {
190
- if (this.peer !== peer) return
191
- this.peer = null
192
- this.role = 'none'
193
- this.log(`The scanruler-mcp holding port ${this.port} has stopped; taking the port over.`)
194
- this.start()
195
- })
196
- this.emit('role', 'relay')
197
- if (peer.connected) this.emit('connected', peer.page)
198
- }
199
-
200
- /** Say why there is no link, once, and try again later. */
201
- #fail(message) {
202
- if (message !== this.error) this.log(message)
203
- this.role = 'none'
204
- this.error = message
205
- clearTimeout(this.timer)
206
- this.timer = setTimeout(() => this.start(), this.retryMs)
207
- this.timer.unref()
208
- }
209
-
210
- close() {
211
- this.closed = true
212
- clearTimeout(this.timer)
213
- this.peer?.close()
214
- this.holder.close()
215
- this.http.close()
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
+ }