scanruler-mcp 0.2.0 → 0.3.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 +364 -241
- package/package.json +1 -1
- package/src/commands.json +3584 -2680
- package/src/engine.js +69 -0
- package/src/link.js +8 -0
- package/src/main.js +71 -4
- package/src/mcp.js +378 -53
- package/src/relay.js +2 -1
- package/src/tools.js +18 -0
- package/src/trace.js +55 -0
package/src/engine.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// The headless backend: ScanRuler itself run in this process, with no
|
|
3
|
+
// browser — no scan, no viewport, nobody watching — in place of the page.
|
|
4
|
+
// The engine is a build of the app for Node (npm run build:engine in a
|
|
5
|
+
// ScanRuler checkout writes dist-engine/engine.mjs), found by its path and
|
|
6
|
+
// loaded as it is: this package names nothing in it and imports nothing from
|
|
7
|
+
// the app. It offers the commands that run without the page, with the same
|
|
8
|
+
// names, inputs and answers as the page's; the rest answer `unavailable`,
|
|
9
|
+
// naming the page mode. One engine to a process — its state is the app's
|
|
10
|
+
// own — so two servers started headless share nothing.
|
|
11
|
+
|
|
12
|
+
import { EventEmitter } from 'node:events'
|
|
13
|
+
import { existsSync } from 'node:fs'
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
15
|
+
import { toolName } from './tools.js'
|
|
16
|
+
|
|
17
|
+
/** Where --headless looks: the engine built in the ScanRuler checkout this
|
|
18
|
+
* package is part of. */
|
|
19
|
+
export const CHECKOUT_ENGINE = fileURLToPath(new URL('../../dist-engine/engine.mjs', import.meta.url))
|
|
20
|
+
|
|
21
|
+
/** Load and start the engine at `file`. Throws, saying what to do, when
|
|
22
|
+
* there is none there or it is no engine. */
|
|
23
|
+
export async function loadEngine(file) {
|
|
24
|
+
if (!existsSync(file)) throw new Error(`There is no ScanRuler engine at ${file}. Build one with "npm run build:engine" in the ScanRuler checkout, or give --engine the path of one.`)
|
|
25
|
+
const mod = await import(pathToFileURL(file).href)
|
|
26
|
+
if (typeof mod.startEngine !== 'function') throw new Error(`${file} is not a ScanRuler engine: it has no startEngine().`)
|
|
27
|
+
return mod.startEngine()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The engine, as the page link is to the server (link.js): always
|
|
31
|
+
* connected, its commands what a page would say it has. */
|
|
32
|
+
export class EngineLink extends EventEmitter {
|
|
33
|
+
/** @param {{ app: string, version: string, plugins: string[], listCommands(): object[], pageCommands(): string[], run(name: string, input: unknown, onProgress?: (text: string) => void): Promise<unknown>, stop(): Promise<void> }} engine */
|
|
34
|
+
constructor(engine, file) {
|
|
35
|
+
super()
|
|
36
|
+
this.engine = engine
|
|
37
|
+
this.file = file
|
|
38
|
+
this.backend = 'headless'
|
|
39
|
+
this.role = 'headless'
|
|
40
|
+
this.error = null
|
|
41
|
+
this.page = { app: engine.app, version: engine.version, origin: null, plugins: engine.plugins, viewport: false, commands: engine.listCommands() }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get connected() {
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A command run in the engine; refused with { code, message } as the
|
|
49
|
+
* page refuses one. */
|
|
50
|
+
run(name, input, _binaryKey, onProgress) {
|
|
51
|
+
return this.engine.run(name, input, onProgress).catch((e) => Promise.reject({ code: e?.code ?? 'failed', message: e?.message ?? String(e) }))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The command behind a tool that needs the page, by the tool's name —
|
|
55
|
+
* null for one the engine has not got at all. */
|
|
56
|
+
pageCommand(tool) {
|
|
57
|
+
return this.engine.pageCommands().find((name) => toolName({ name }) === tool) ?? null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
waitForPage() {
|
|
61
|
+
return Promise.resolve(true)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
start() {}
|
|
65
|
+
|
|
66
|
+
close() {
|
|
67
|
+
void this.engine.stop()
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/link.js
CHANGED
|
@@ -247,6 +247,14 @@ export class PageLink extends Asker {
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
#receive(message) {
|
|
250
|
+
// The tab hidden or shown again — in the background, its window
|
|
251
|
+
// covered, the screen locked — for the status to say.
|
|
252
|
+
if (message.event === 'visibility') {
|
|
253
|
+
this.page = { ...this.page, hidden: message.hidden === true }
|
|
254
|
+
this.#tellPeers()
|
|
255
|
+
this.emit('visibility', this.page.hidden)
|
|
256
|
+
return
|
|
257
|
+
}
|
|
250
258
|
if (message.event === 'commands' && Array.isArray(message.commands)) {
|
|
251
259
|
this.page = { ...this.page, commands: message.commands }
|
|
252
260
|
this.#tellPeers()
|
package/src/main.js
CHANGED
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
// else. Everything this prints goes to stderr — stdout is the MCP channel.
|
|
7
7
|
|
|
8
8
|
import { readFileSync } from 'node:fs'
|
|
9
|
-
import {
|
|
9
|
+
import { resolve } from 'node:path'
|
|
10
|
+
import { format, parseArgs } from 'node:util'
|
|
10
11
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
11
12
|
import { openInBrowser } from './browser.js'
|
|
12
13
|
import { configDir, loadConfig, loadRemembered, saveRemembered } from './config.js'
|
|
14
|
+
import { CHECKOUT_ENGINE, EngineLink, loadEngine } from './engine.js'
|
|
13
15
|
import { originAllowed } from './link.js'
|
|
14
|
-
import { createMcpServer } from './mcp.js'
|
|
16
|
+
import { createMcpServer, TOOL_MODES } from './mcp.js'
|
|
15
17
|
import { SharedLink } from './relay.js'
|
|
18
|
+
import { createTrace } from './trace.js'
|
|
16
19
|
import { httpServer } from './serve.js'
|
|
17
20
|
|
|
18
21
|
const DEFAULT_PORT = 7317
|
|
@@ -30,16 +33,64 @@ Usage: scanruler-mcp [options]
|
|
|
30
33
|
--serve <dir> also serve a built ScanRuler from <dir> at http://127.0.0.1:<port>/
|
|
31
34
|
--allow-origin <url> let a ScanRuler page from another origin connect (repeatable)
|
|
32
35
|
--app <url> the ScanRuler address the pairing link opens (default ${HOSTED_APP})
|
|
36
|
+
--engine <file> run ScanRuler headless in this process, from an engine built
|
|
37
|
+
with "npm run build:engine" (SCANRULER_ENGINE) — no browser,
|
|
38
|
+
no page: modelling and files only
|
|
39
|
+
--headless the same, with the engine built in the ScanRuler checkout
|
|
40
|
+
this server is part of
|
|
41
|
+
--tools <mode> full (the default) lists each of ScanRuler's commands as a tool;
|
|
42
|
+
compact lists five and the commands that return a picture, the
|
|
43
|
+
rest read through scanruler_tools and run through scanruler_batch
|
|
44
|
+
— for a small context window
|
|
45
|
+
--trace <dir> write a line per tool call — tool, ms, bytes each way, error,
|
|
46
|
+
whether the tab was hidden — to a new file in <dir>, for
|
|
47
|
+
measuring a run (scripts/trace-summary.mjs in the ScanRuler
|
|
48
|
+
repository sums it up); off by default
|
|
49
|
+
--trace-args with --trace, also write each call's arguments (otherwise
|
|
50
|
+
only their size)
|
|
33
51
|
--pair print the pairing token and link, and exit
|
|
34
52
|
--help this
|
|
35
53
|
|
|
36
54
|
The pairing token is made on the first run and kept in ${join2(configDir(), 'config.json')};
|
|
37
|
-
SCANRULER_MCP_TOKEN overrides it.
|
|
55
|
+
SCANRULER_MCP_TOKEN overrides it. SCANRULER_MCP_PORT, SCANRULER_MCP_TOOLS and
|
|
56
|
+
SCANRULER_MCP_TRACE stand in for --port, --tools and --trace. The agent's scanruler_open tool opens ScanRuler
|
|
38
57
|
in the default browser, paired. By hand: open the pairing link once, or in
|
|
39
58
|
ScanRuler go to Settings → Agents → Local agent connection and enter the port
|
|
40
59
|
and the token.
|
|
41
60
|
`
|
|
42
61
|
|
|
62
|
+
/** ScanRuler run in this process from the engine at `file`, in place of
|
|
63
|
+
* the page — no port, no pairing. */
|
|
64
|
+
async function headless(file, { cwd, outDir, tools, trace }) {
|
|
65
|
+
// stdout is the MCP channel, the transport's alone: whatever else writes
|
|
66
|
+
// to it — the app's logging, the kernel's own reports — goes to stderr.
|
|
67
|
+
const stdout = process.stdout
|
|
68
|
+
const write = stdout.write.bind(stdout)
|
|
69
|
+
const channel = { write, once: (event, listener) => stdout.once(event, listener) }
|
|
70
|
+
stdout.write = (...args) => process.stderr.write(...args)
|
|
71
|
+
for (const name of ['log', 'info', 'warn', 'debug']) console[name] = (...args) => process.stderr.write(`${format(...args)}\n`)
|
|
72
|
+
const engine = await loadEngine(file)
|
|
73
|
+
const link = new EngineLink(engine, file)
|
|
74
|
+
log(`Running ScanRuler ${engine.version} headless from ${file}, with ${link.page.commands.length} commands.`)
|
|
75
|
+
const server = createMcpServer({
|
|
76
|
+
link,
|
|
77
|
+
version: VERSION,
|
|
78
|
+
cwd,
|
|
79
|
+
outDir,
|
|
80
|
+
connectHelp: () => 'This server runs ScanRuler headless, in its own process; no page connects to it.',
|
|
81
|
+
serverInfo: () => ({ name: 'scanruler-mcp', version: VERSION, backend: 'headless', engine: file, tools, outDir, ...(trace ? { trace: trace.file } : {}) }),
|
|
82
|
+
remembered: null,
|
|
83
|
+
tools,
|
|
84
|
+
trace,
|
|
85
|
+
})
|
|
86
|
+
server.onclose = () => {
|
|
87
|
+
link.close()
|
|
88
|
+
process.exit(0)
|
|
89
|
+
}
|
|
90
|
+
await server.connect(new StdioServerTransport(process.stdin, channel))
|
|
91
|
+
log('Ready, headless.')
|
|
92
|
+
}
|
|
93
|
+
|
|
43
94
|
function join2(a, b) {
|
|
44
95
|
return `${a}${a.includes('\\') ? '\\' : '/'}${b}`
|
|
45
96
|
}
|
|
@@ -55,6 +106,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
55
106
|
serve: { type: 'string' },
|
|
56
107
|
'allow-origin': { type: 'string', multiple: true },
|
|
57
108
|
app: { type: 'string' },
|
|
109
|
+
engine: { type: 'string' },
|
|
110
|
+
headless: { type: 'boolean' },
|
|
111
|
+
tools: { type: 'string' },
|
|
112
|
+
trace: { type: 'string' },
|
|
113
|
+
'trace-args': { type: 'boolean' },
|
|
58
114
|
pair: { type: 'boolean' },
|
|
59
115
|
help: { type: 'boolean' },
|
|
60
116
|
},
|
|
@@ -66,6 +122,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
66
122
|
}
|
|
67
123
|
const port = Number(values.port ?? process.env.SCANRULER_MCP_PORT ?? DEFAULT_PORT)
|
|
68
124
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`--port ${values.port} is not a port.`)
|
|
125
|
+
const tools = values.tools ?? (process.env.SCANRULER_MCP_TOOLS || 'full')
|
|
126
|
+
if (!TOOL_MODES.includes(tools)) {
|
|
127
|
+
throw new Error(`${values.tools === undefined ? 'SCANRULER_MCP_TOOLS' : '--tools'} ${tools} is not one of ${TOOL_MODES.join(', ')}.`)
|
|
128
|
+
}
|
|
69
129
|
const config = loadConfig()
|
|
70
130
|
const token = process.env.SCANRULER_MCP_TOKEN || config.token
|
|
71
131
|
const app = values.app ?? (values.serve ? `http://127.0.0.1:${port}/` : HOSTED_APP)
|
|
@@ -79,6 +139,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
79
139
|
|
|
80
140
|
const cwd = process.cwd()
|
|
81
141
|
const outDir = values.out ? (values.out.match(/^([A-Za-z]:)?[\\/]/) ? values.out : `${cwd}/${values.out}`) : cwd
|
|
142
|
+
const traceDir = values.trace ?? process.env.SCANRULER_MCP_TRACE
|
|
143
|
+
const trace = traceDir ? createTrace(resolve(cwd, traceDir), { args: values['trace-args'] === true }) : null
|
|
144
|
+
if (trace) log(`Tracing every call to ${trace.file}${trace.args ? ', arguments too' : ''}.`)
|
|
145
|
+
const engineFile = values.engine ?? process.env.SCANRULER_ENGINE ?? (values.headless ? CHECKOUT_ENGINE : null)
|
|
146
|
+
if (engineFile) return headless(resolve(cwd, engineFile), { cwd, outDir, tools, trace })
|
|
82
147
|
const origins = values['allow-origin'] ?? []
|
|
83
148
|
const http = httpServer({ serve: values.serve, allowOrigin: (origin) => originAllowed(origin, origins), version: VERSION })
|
|
84
149
|
// The port, or — when another copy holds it — the relay through that one.
|
|
@@ -96,9 +161,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
96
161
|
cwd,
|
|
97
162
|
outDir,
|
|
98
163
|
connectHelp: () => (link.error ? `${link.error} ` : '') + connectHelp(),
|
|
99
|
-
serverInfo: () => ({ name: 'scanruler-mcp', version: VERSION, port, role: link.role, outDir, config: config.file, ...(link.error ? { error: link.error } : {}) }),
|
|
164
|
+
serverInfo: () => ({ name: 'scanruler-mcp', version: VERSION, port, role: link.role, tools, outDir, config: config.file, ...(trace ? { trace: trace.file } : {}), ...(link.error ? { error: link.error } : {}) }),
|
|
100
165
|
remembered: { load: () => loadRemembered(), save: (commands) => saveRemembered(commands) },
|
|
101
166
|
openPage: () => openInBrowser(pairing),
|
|
167
|
+
tools,
|
|
168
|
+
trace,
|
|
102
169
|
})
|
|
103
170
|
const transport = new StdioServerTransport()
|
|
104
171
|
server.onclose = () => {
|