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/README.md +431 -241
- package/bin/scanruler-mcp.js +15 -8
- package/package.json +1 -1
- package/src/commands.json +4435 -2504
- package/src/config.js +38 -6
- package/src/engine.js +69 -0
- package/src/link.js +304 -285
- package/src/main.js +105 -5
- package/src/mcp.js +1155 -290
- package/src/relay.js +218 -217
- package/src/tools.js +207 -14
- package/src/trace.js +55 -0
- package/src/update.js +380 -0
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 {
|
|
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.
|
|
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 = () => {
|