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/config.js CHANGED
@@ -3,7 +3,10 @@
3
3
  // has to present, made on the first run and kept in the user's config
4
4
  // folder so it is pasted into ScanRuler's Settings once, not every time —
5
5
  // and the commands the last page that connected had, so an agent that lists
6
- // the tools once, before a page is back, still sees all of them.
6
+ // the tools once, before a page is back, still sees all of them — and the
7
+ // folders the scans were opened from, where its files go. The command list
8
+ // carries the page's version: a server shipped with a newer list of its own
9
+ // lists that one (mcp.js).
7
10
 
8
11
  import { randomBytes } from 'node:crypto'
9
12
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
@@ -22,20 +25,49 @@ export function configDir(env = process.env, platform = process.platform) {
22
25
  /** A new token: 24 random bytes, URL-safe — it travels in a link. */
23
26
  const newToken = () => randomBytes(24).toString('base64url')
24
27
 
25
- /** The command list the last connected page sent, or null. */
28
+ /** The command list the last connected page sent, with the page's version
29
+ * and plugins and when it was kept — `{ commands, version, plugins, saved }`
30
+ * — or null. A list kept by a server before 0.4.0 has no version. */
26
31
  export function loadRemembered(dir = configDir()) {
27
32
  try {
28
33
  const kept = JSON.parse(readFileSync(join(dir, 'tools.json'), 'utf8'))
29
- return Array.isArray(kept.commands) && kept.commands.length > 0 ? kept.commands : null
34
+ if (!Array.isArray(kept.commands) || kept.commands.length === 0) return null
35
+ return {
36
+ commands: kept.commands,
37
+ version: typeof kept.version === 'string' ? kept.version : null,
38
+ plugins: Array.isArray(kept.plugins) ? kept.plugins.map(String) : null,
39
+ saved: typeof kept.saved === 'string' ? kept.saved : null,
40
+ }
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ /** Keep a page's command list for the next run: `{ commands, version,
47
+ * plugins }` — the page's version, for the next run to tell whether the
48
+ * list it was shipped with is newer — or the list alone. */
49
+ export function saveRemembered(kept, dir = configDir()) {
50
+ const { commands, version = null, plugins = null } = Array.isArray(kept) ? { commands: kept } : kept
51
+ mkdirSync(dir, { recursive: true })
52
+ writeFileSync(join(dir, 'tools.json'), JSON.stringify({ saved: new Date().toISOString(), version, plugins, commands }) + '\n')
53
+ }
54
+
55
+ /** The folders scans and projects were opened from or saved into, the
56
+ * latest first, or null — kept beside the command list, so a copy started
57
+ * afresh still writes and lists where the last one did. */
58
+ export function loadFolders(dir = configDir()) {
59
+ try {
60
+ const kept = JSON.parse(readFileSync(join(dir, 'folders.json'), 'utf8'))
61
+ return Array.isArray(kept.folders) && kept.folders.every((f) => typeof f === 'string') ? kept.folders : null
30
62
  } catch {
31
63
  return null
32
64
  }
33
65
  }
34
66
 
35
- /** Keep a page's command list for the next run. */
36
- export function saveRemembered(commands, dir = configDir()) {
67
+ /** Keep the folders for the next run, and for the other copies. */
68
+ export function saveFolders(folders, dir = configDir()) {
37
69
  mkdirSync(dir, { recursive: true })
38
- writeFileSync(join(dir, 'tools.json'), JSON.stringify({ saved: new Date().toISOString(), commands }) + '\n')
70
+ writeFileSync(join(dir, 'folders.json'), JSON.stringify({ saved: new Date().toISOString(), folders }) + '\n')
39
71
  }
40
72
 
41
73
  /** The stored config, with a token made and saved if there was none. */
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
+ }