scanruler-mcp 0.1.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/src/config.js CHANGED
@@ -20,7 +20,7 @@ export function configDir(env = process.env, platform = process.platform) {
20
20
  }
21
21
 
22
22
  /** A new token: 24 random bytes, URL-safe — it travels in a link. */
23
- export const newToken = () => randomBytes(24).toString('base64url')
23
+ const newToken = () => randomBytes(24).toString('base64url')
24
24
 
25
25
  /** The command list the last connected page sent, or null. */
26
26
  export function loadRemembered(dir = configDir()) {
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
@@ -17,7 +17,7 @@ import { decoder, encode } from './framing.js'
17
17
 
18
18
  /** The hosted app. Loopback pages — a dev server, `--serve` — are allowed
19
19
  * on any port; anything else takes `--allow-origin`. */
20
- export const HOSTED_ORIGINS = ['https://scanruler.com', 'https://www.scanruler.com']
20
+ const HOSTED_ORIGINS = ['https://scanruler.com', 'https://www.scanruler.com']
21
21
 
22
22
  const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]'])
23
23
 
@@ -43,7 +43,7 @@ const HEARTBEAT_MS = 15_000
43
43
  const HELLO_MS = 10_000
44
44
 
45
45
  /** Where a result carries a file's bytes, if it does. */
46
- export const resultBinary = (result) => (result?.file?.bytes instanceof Uint8Array ? 'result.file.bytes' : undefined)
46
+ const resultBinary = (result) => (result?.file?.bytes instanceof Uint8Array ? 'result.file.bytes' : undefined)
47
47
 
48
48
  /** A socket's frame, as the decoder takes it. */
49
49
  export const frameOf = (data, isBinary) => (isBinary ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) : data.toString('utf8'))
@@ -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,17 +6,20 @@
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 { parseArgs } from 'node:util'
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
- export const DEFAULT_PORT = 7317
19
- export const HOSTED_APP = 'https://scanruler.com/'
21
+ const DEFAULT_PORT = 7317
22
+ const HOSTED_APP = 'https://scanruler.com/'
20
23
 
21
24
  const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
22
25
 
@@ -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. The agent's scanruler_open tool opens ScanRuler
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,12 +122,16 @@ 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)
72
132
  const pairing = `${app.replace(/#.*$/, '')}#agent=${port}:${token}`
73
133
  const connectHelp = () =>
74
- `Open ScanRuler — ${app} — in Chrome or Edge and either open this pairing link once: ${pairing} — or in ScanRuler go to Settings → Agents, switch on "Local agent connection", and enter port ${port} and token ${token}.`
134
+ `Open ScanRuler — ${app} — in Chrome, Edge or Firefox and either open this pairing link once: ${pairing} — or in ScanRuler go to Settings → Agents, switch on "Local agent connection", and enter port ${port} and token ${token}.`
75
135
  if (values.pair) {
76
136
  process.stdout.write(`Port: ${port}\nToken: ${token}\nLink: ${pairing}\n`)
77
137
  return
@@ -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 = () => {