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/main.js CHANGED
@@ -6,14 +6,18 @@
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
- import { configDir, loadConfig, loadRemembered, saveRemembered } from './config.js'
13
+ import { configDir, loadConfig, loadFolders, loadRemembered, saveFolders, 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 { ANSWER_WITHIN_MS, 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'
20
+ import { NO_UPDATE_ENV, startUpdates, watchParent } from './update.js'
17
21
 
18
22
  const DEFAULT_PORT = 7317
19
23
  const HOSTED_APP = 'https://scanruler.com/'
@@ -30,16 +34,78 @@ Usage: scanruler-mcp [options]
30
34
  --serve <dir> also serve a built ScanRuler from <dir> at http://127.0.0.1:<port>/
31
35
  --allow-origin <url> let a ScanRuler page from another origin connect (repeatable)
32
36
  --app <url> the ScanRuler address the pairing link opens (default ${HOSTED_APP})
37
+ --engine <file> run ScanRuler headless in this process, from an engine built
38
+ with "npm run build:engine" (SCANRULER_ENGINE) — no browser,
39
+ no page: modelling and files only
40
+ --headless the same, with the engine built in the ScanRuler checkout
41
+ this server is part of
42
+ --tools <mode> full (the default) lists each of ScanRuler's commands as a tool;
43
+ compact lists the server's own and the commands that return a
44
+ picture, the rest read through scanruler_tools and run through
45
+ scanruler_batch — for a small context window
46
+ --trace <dir> write a line per tool call — tool, ms, bytes each way, error,
47
+ whether the tab was hidden — to a new file in <dir>, for
48
+ measuring a run (scripts/trace-summary.mjs in the ScanRuler
49
+ repository sums it up); off by default
50
+ --trace-args with --trace, also write each call's arguments (otherwise
51
+ only their size)
52
+ --answer-within <s> hold a call at most this many seconds (default ${ANSWER_WITHIN_MS / 1000}): a change,
53
+ a batch or a long read still running then answers with its job
54
+ and runs on, and scanruler_wait waits no longer — under the
55
+ minute a client or a bridge may cut a call at; 0 holds a call
56
+ to its end
57
+ --no-update do not keep this server current (${NO_UPDATE_ENV}=1)
33
58
  --pair print the pairing token and link, and exit
59
+ --version print this server's version, and exit
34
60
  --help this
35
61
 
62
+ Once a day, in the background, the server asks npm's registry for its latest
63
+ version; when there is a newer one, npm fetches it, and the next start runs it
64
+ in this one's place. Not for a copy run from a git checkout, a version pinned
65
+ in the agent's config (scanruler-mcp@0.4.0), or with --no-update.
66
+
36
67
  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
68
+ SCANRULER_MCP_TOKEN overrides it. SCANRULER_MCP_PORT, SCANRULER_MCP_TOOLS,
69
+ SCANRULER_MCP_TRACE and SCANRULER_MCP_ANSWER_WITHIN stand in for --port, --tools,
70
+ --trace and --answer-within. The agent's scanruler_open tool opens ScanRuler
38
71
  in the default browser, paired. By hand: open the pairing link once, or in
39
72
  ScanRuler go to Settings → Agents → Local agent connection and enter the port
40
73
  and the token.
41
74
  `
42
75
 
76
+ /** ScanRuler run in this process from the engine at `file`, in place of
77
+ * the page — no port, no pairing. */
78
+ async function headless(file, { cwd, outDir, tools, trace, answerWithinMs, updates }) {
79
+ // stdout is the MCP channel, the transport's alone: whatever else writes
80
+ // to it — the app's logging, the kernel's own reports — goes to stderr.
81
+ const stdout = process.stdout
82
+ const write = stdout.write.bind(stdout)
83
+ const channel = { write, once: (event, listener) => stdout.once(event, listener) }
84
+ stdout.write = (...args) => process.stderr.write(...args)
85
+ for (const name of ['log', 'info', 'warn', 'debug']) console[name] = (...args) => process.stderr.write(`${format(...args)}\n`)
86
+ const engine = await loadEngine(file)
87
+ const link = new EngineLink(engine, file)
88
+ log(`Running ScanRuler ${engine.version} headless from ${file}, with ${link.page.commands.length} commands.`)
89
+ const server = createMcpServer({
90
+ link,
91
+ version: VERSION,
92
+ cwd,
93
+ outDir,
94
+ connectHelp: () => 'This server runs ScanRuler headless, in its own process; no page connects to it.',
95
+ serverInfo: () => ({ name: 'scanruler-mcp', version: VERSION, ...updates.info(), backend: 'headless', engine: file, tools, outDir, ...(trace ? { trace: trace.file } : {}) }),
96
+ remembered: null,
97
+ tools,
98
+ trace,
99
+ answerWithinMs,
100
+ })
101
+ server.onclose = () => {
102
+ link.close()
103
+ process.exit(0)
104
+ }
105
+ await server.connect(new StdioServerTransport(process.stdin, channel))
106
+ log('Ready, headless.')
107
+ }
108
+
43
109
  function join2(a, b) {
44
110
  return `${a}${a.includes('\\') ? '\\' : '/'}${b}`
45
111
  }
@@ -55,7 +121,15 @@ export async function main(argv = process.argv.slice(2)) {
55
121
  serve: { type: 'string' },
56
122
  'allow-origin': { type: 'string', multiple: true },
57
123
  app: { type: 'string' },
124
+ engine: { type: 'string' },
125
+ headless: { type: 'boolean' },
126
+ tools: { type: 'string' },
127
+ trace: { type: 'string' },
128
+ 'trace-args': { type: 'boolean' },
129
+ 'answer-within': { type: 'string' },
130
+ 'no-update': { type: 'boolean' },
58
131
  pair: { type: 'boolean' },
132
+ version: { type: 'boolean' },
59
133
  help: { type: 'boolean' },
60
134
  },
61
135
  strict: true,
@@ -64,8 +138,21 @@ export async function main(argv = process.argv.slice(2)) {
64
138
  process.stderr.write(HELP)
65
139
  return
66
140
  }
141
+ if (values.version) {
142
+ process.stdout.write(`${VERSION}\n`)
143
+ return
144
+ }
67
145
  const port = Number(values.port ?? process.env.SCANRULER_MCP_PORT ?? DEFAULT_PORT)
68
146
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`--port ${values.port} is not a port.`)
147
+ const tools = values.tools ?? (process.env.SCANRULER_MCP_TOOLS || 'full')
148
+ if (!TOOL_MODES.includes(tools)) {
149
+ throw new Error(`${values.tools === undefined ? 'SCANRULER_MCP_TOOLS' : '--tools'} ${tools} is not one of ${TOOL_MODES.join(', ')}.`)
150
+ }
151
+ const within = values['answer-within'] ?? process.env.SCANRULER_MCP_ANSWER_WITHIN
152
+ const answerWithinMs = within === undefined || within === '' ? ANSWER_WITHIN_MS : Number(within) * 1000
153
+ if (!Number.isFinite(answerWithinMs) || answerWithinMs < 0) {
154
+ throw new Error(`${values['answer-within'] === undefined ? 'SCANRULER_MCP_ANSWER_WITHIN' : '--answer-within'} ${within} is not a number of seconds.`)
155
+ }
69
156
  const config = loadConfig()
70
157
  const token = process.env.SCANRULER_MCP_TOKEN || config.token
71
158
  const app = values.app ?? (values.serve ? `http://127.0.0.1:${port}/` : HOSTED_APP)
@@ -77,8 +164,17 @@ export async function main(argv = process.argv.slice(2)) {
77
164
  return
78
165
  }
79
166
 
167
+ // Kept current in the background (update.js); a copy handed to stops
168
+ // with the launcher that started it.
169
+ const updates = startUpdates({ version: VERSION, argv })
170
+ watchParent()
80
171
  const cwd = process.cwd()
81
172
  const outDir = values.out ? (values.out.match(/^([A-Za-z]:)?[\\/]/) ? values.out : `${cwd}/${values.out}`) : cwd
173
+ const traceDir = values.trace ?? process.env.SCANRULER_MCP_TRACE
174
+ const trace = traceDir ? createTrace(resolve(cwd, traceDir), { args: values['trace-args'] === true }) : null
175
+ if (trace) log(`Tracing every call to ${trace.file}${trace.args ? ', arguments too' : ''}.`)
176
+ const engineFile = values.engine ?? process.env.SCANRULER_ENGINE ?? (values.headless ? CHECKOUT_ENGINE : null)
177
+ if (engineFile) return headless(resolve(cwd, engineFile), { cwd, outDir, tools, trace, answerWithinMs, updates })
82
178
  const origins = values['allow-origin'] ?? []
83
179
  const http = httpServer({ serve: values.serve, allowOrigin: (origin) => originAllowed(origin, origins), version: VERSION })
84
180
  // The port, or — when another copy holds it — the relay through that one.
@@ -96,9 +192,13 @@ export async function main(argv = process.argv.slice(2)) {
96
192
  cwd,
97
193
  outDir,
98
194
  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 } : {}) }),
195
+ serverInfo: () => ({ name: 'scanruler-mcp', version: VERSION, ...updates.info(), port, role: link.role, tools, outDir, config: config.file, ...(trace ? { trace: trace.file } : {}), ...(link.error ? { error: link.error } : {}) }),
100
196
  remembered: { load: () => loadRemembered(), save: (commands) => saveRemembered(commands) },
197
+ folderStore: { load: () => loadFolders(), save: (folders) => saveFolders(folders) },
101
198
  openPage: () => openInBrowser(pairing),
199
+ tools,
200
+ trace,
201
+ answerWithinMs,
102
202
  })
103
203
  const transport = new StdioServerTransport()
104
204
  server.onclose = () => {