src / main.ts
src / main.ts
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as readline from "node:readline/promises";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { RULE_COMMANDS, ruleCommand, statusReport } from "./commands";
import { KNOWN_ENGINES, VERSION, loadConfig } from "./config";
import { createCtx, selfTest } from "./ops";
import { toolSpecs } from "./tools";
const text = (t: string) => ({ content: [{ type: "text" as const, text: t }] });
async function runServer(): Promise<void> {
const log = (msg: string) => process.stderr.write(`[simpleSearch] ${msg}\n`);
const cfg = loadConfig();
for (const p of cfg.problems) log(`config: ${p}`);
const ctx = createCtx(cfg, log);
await ctx.bridge.start();
const server = new McpServer({ name: "simpleSearch", version: VERSION });
for (const t of toolSpecs(ctx)) {
server.registerTool(t.name, { description: t.description, inputSchema: t.shape }, async (args: Record<string, unknown>) =>
text(await t.run(args as never))
);
}
const transport = new StdioServerTransport();
await server.connect(transport);
log(`ready (version ${VERSION})`);
const shutdown = () => {
void ctx.bridge.close();
void server.close();
process.exit(0);
};
process.stdin.on("close", shutdown);
process.on("SIGTERM", shutdown);
}
// The command line is used from PowerShell, where the variables in mcp.json are not set.
// Read them from LM Studio's mcp.json so both share one configuration.
function importLmStudioEnv(): string | null {
const file = path.join(os.homedir(), ".lmstudio", "mcp.json");
let parsed: { mcpServers?: Record<string, { args?: unknown; env?: Record<string, unknown> }> };
try {
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return null;
}
const self = path.win32.basename(process.argv[1] ?? "simplesearch.js").toLowerCase();
for (const entry of Object.values(parsed.mcpServers ?? {})) {
const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
if (!args.some((a) => path.win32.basename(a).toLowerCase() === self)) continue;
for (const [key, value] of Object.entries(entry.env ?? {})) {
if (key.startsWith("SIMPLESEARCH_") && process.env[key] === undefined && typeof value === "string") {
process.env[key] = value;
}
}
return file;
}
return null;
}
// Writes the simpleSearch entry into LM Studio's mcp.json, keeping a backup and every
// other server, and removes an older Browser Control MCP entry if one is present.
function setupLmStudio(secret: string): number {
if (!/^[0-9a-f]{64}$/i.test(secret)) {
console.log("That does not look like the secret from the extension's options page. It should be 64 characters of 0-9 and a-f.");
return 1;
}
const dir = path.join(os.homedir(), ".lmstudio");
const file = path.join(dir, "mcp.json");
let config: { mcpServers?: Record<string, { args?: unknown }> } = {};
if (fs.existsSync(file)) {
const raw = fs.readFileSync(file, "utf8");
try {
config = raw.trim() ? JSON.parse(raw) : {};
} catch {
console.log(`${file} is not valid JSON, so it was left untouched. Fix it in LM Studio's mcp.json editor and run setup again.`);
return 1;
}
const backup = `${file}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
fs.copyFileSync(file, backup);
console.log(`Backed up the current mcp.json to ${backup}`);
} else {
fs.mkdirSync(dir, { recursive: true });
}
const servers = (config.mcpServers ??= {});
for (const [name, entry] of Object.entries(servers)) {
const args = Array.isArray(entry?.args) ? entry.args.map(String) : [];
if (args.some((a) => path.win32.basename(a).toLowerCase() === "browser-control-mcp-server.js")) {
delete servers[name];
console.log(`Removed the old "${name}" entry, which simpleSearch replaces.`);
}
}
servers.simplesearch = {
command: "node",
args: [path.resolve(process.argv[1] ?? "simplesearch.js")],
env: { SIMPLESEARCH_SECRET: secret.toLowerCase() },
} as { args: unknown };
fs.writeFileSync(file, JSON.stringify(config, null, 2), "utf8");
console.log(`Added simpleSearch to ${file}`);
console.log("Restart LM Studio so it starts the new server.");
return 0;
}
const USAGE = `simpleSearch ${VERSION}
Without arguments this file runs as an MCP server (that is how LM Studio starts it).
Commands:
setup add simpleSearch to LM Studio's mcp.json (asks for the extension's secret)
status show configuration, engine history and proposed rules
selftest test the browser connection and each engine (close LM Studio first)
approve <engine> accept the proposed parser rules for an engine
reject <engine> discard the proposed parser rules for an engine
rollback <engine> go back to the rules an engine had before the last change
reset <engine> go back to the built-in default rules for an engine
Engines: ${KNOWN_ENGINES.join(", ")}`;
async function runCli(argv: string[]): Promise<number> {
const [command, arg] = argv;
if (command === "help" || command === "--help" || command === "-h") {
console.log(USAGE);
return 0;
}
if (command === "setup") {
let secret = (arg ?? "").trim();
if (!secret) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
secret = (await rl.question("Paste the secret from the extension's options page, then press Enter: ")).trim();
rl.close();
}
return setupLmStudio(secret);
}
const source = importLmStudioEnv();
const cfg = loadConfig();
const ctx = createCtx(cfg, () => {});
if ((RULE_COMMANDS as readonly string[]).includes(command)) {
console.log(ruleCommand(ctx, command, arg));
return 0;
}
switch (command) {
case "status":
console.log(statusReport(ctx, source ?? "environment variables only (no matching entry in LM Studio's mcp.json)"));
return 0;
case "selftest": {
await ctx.bridge.start();
if (!ctx.bridge.isListening()) {
console.log(ctx.bridge.bindError ?? "Could not start.");
console.log("If LM Studio is open, it is already running the server. Ask the model to run search_self_test instead, or close LM Studio and try again.");
return 1;
}
console.log("Testing. This can take up to a minute...");
console.log(await selfTest(ctx));
await ctx.bridge.close();
return 0;
}
default:
console.log(USAGE);
return command ? 1 : 0;
}
}
if (process.argv.length > 2) {
runCli(process.argv.slice(2)).then(
(code) => process.exit(code),
(err) => {
console.error(err);
process.exit(1);
}
);
} else {
runServer().catch((err) => {
process.stderr.write(`[simpleSearch] fatal: ${(err as Error).stack ?? err}\n`);
process.exit(1);
});
}
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as readline from "node:readline/promises";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { RULE_COMMANDS, ruleCommand, statusReport } from "./commands";
import { KNOWN_ENGINES, VERSION, loadConfig } from "./config";
import { createCtx, selfTest } from "./ops";
import { toolSpecs } from "./tools";
const text = (t: string) => ({ content: [{ type: "text" as const, text: t }] });
async function runServer(): Promise<void> {
const log = (msg: string) => process.stderr.write(`[simpleSearch] ${msg}\n`);
const cfg = loadConfig();
for (const p of cfg.problems) log(`config: ${p}`);
const ctx = createCtx(cfg, log);
await ctx.bridge.start();
const server = new McpServer({ name: "simpleSearch", version: VERSION });
for (const t of toolSpecs(ctx)) {
server.registerTool(t.name, { description: t.description, inputSchema: t.shape }, async (args: Record<string, unknown>) =>
text(await t.run(args as never))
);
}
const transport = new StdioServerTransport();
await server.connect(transport);
log(`ready (version ${VERSION})`);
const shutdown = () => {
void ctx.bridge.close();
void server.close();
process.exit(0);
};
process.stdin.on("close", shutdown);
process.on("SIGTERM", shutdown);
}
// The command line is used from PowerShell, where the variables in mcp.json are not set.
// Read them from LM Studio's mcp.json so both share one configuration.
function importLmStudioEnv(): string | null {
const file = path.join(os.homedir(), ".lmstudio", "mcp.json");
let parsed: { mcpServers?: Record<string, { args?: unknown; env?: Record<string, unknown> }> };
try {
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return null;
}
const self = path.win32.basename(process.argv[1] ?? "simplesearch.js").toLowerCase();
for (const entry of Object.values(parsed.mcpServers ?? {})) {
const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
if (!args.some((a) => path.win32.basename(a).toLowerCase() === self)) continue;
for (const [key, value] of Object.entries(entry.env ?? {})) {
if (key.startsWith("SIMPLESEARCH_") && process.env[key] === undefined && typeof value === "string") {
process.env[key] = value;
}
}
return file;
}
return null;
}
// Writes the simpleSearch entry into LM Studio's mcp.json, keeping a backup and every
// other server, and removes an older Browser Control MCP entry if one is present.
function setupLmStudio(secret: string): number {
if (!/^[0-9a-f]{64}$/i.test(secret)) {
console.log("That does not look like the secret from the extension's options page. It should be 64 characters of 0-9 and a-f.");
return 1;
}
const dir = path.join(os.homedir(), ".lmstudio");
const file = path.join(dir, "mcp.json");
let config: { mcpServers?: Record<string, { args?: unknown }> } = {};
if (fs.existsSync(file)) {
const raw = fs.readFileSync(file, "utf8");
try {
config = raw.trim() ? JSON.parse(raw) : {};
} catch {
console.log(`${file} is not valid JSON, so it was left untouched. Fix it in LM Studio's mcp.json editor and run setup again.`);
return 1;
}
const backup = `${file}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
fs.copyFileSync(file, backup);
console.log(`Backed up the current mcp.json to ${backup}`);
} else {
fs.mkdirSync(dir, { recursive: true });
}
const servers = (config.mcpServers ??= {});
for (const [name, entry] of Object.entries(servers)) {
const args = Array.isArray(entry?.args) ? entry.args.map(String) : [];
if (args.some((a) => path.win32.basename(a).toLowerCase() === "browser-control-mcp-server.js")) {
delete servers[name];
console.log(`Removed the old "${name}" entry, which simpleSearch replaces.`);
}
}
servers.simplesearch = {
command: "node",
args: [path.resolve(process.argv[1] ?? "simplesearch.js")],
env: { SIMPLESEARCH_SECRET: secret.toLowerCase() },
} as { args: unknown };
fs.writeFileSync(file, JSON.stringify(config, null, 2), "utf8");
console.log(`Added simpleSearch to ${file}`);
console.log("Restart LM Studio so it starts the new server.");
return 0;
}
const USAGE = `simpleSearch ${VERSION}
Without arguments this file runs as an MCP server (that is how LM Studio starts it).
Commands:
setup add simpleSearch to LM Studio's mcp.json (asks for the extension's secret)
status show configuration, engine history and proposed rules
selftest test the browser connection and each engine (close LM Studio first)
approve <engine> accept the proposed parser rules for an engine
reject <engine> discard the proposed parser rules for an engine
rollback <engine> go back to the rules an engine had before the last change
reset <engine> go back to the built-in default rules for an engine
Engines: ${KNOWN_ENGINES.join(", ")}`;
async function runCli(argv: string[]): Promise<number> {
const [command, arg] = argv;
if (command === "help" || command === "--help" || command === "-h") {
console.log(USAGE);
return 0;
}
if (command === "setup") {
let secret = (arg ?? "").trim();
if (!secret) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
secret = (await rl.question("Paste the secret from the extension's options page, then press Enter: ")).trim();
rl.close();
}
return setupLmStudio(secret);
}
const source = importLmStudioEnv();
const cfg = loadConfig();
const ctx = createCtx(cfg, () => {});
if ((RULE_COMMANDS as readonly string[]).includes(command)) {
console.log(ruleCommand(ctx, command, arg));
return 0;
}
switch (command) {
case "status":
console.log(statusReport(ctx, source ?? "environment variables only (no matching entry in LM Studio's mcp.json)"));
return 0;
case "selftest": {
await ctx.bridge.start();
if (!ctx.bridge.isListening()) {
console.log(ctx.bridge.bindError ?? "Could not start.");
console.log("If LM Studio is open, it is already running the server. Ask the model to run search_self_test instead, or close LM Studio and try again.");
return 1;
}
console.log("Testing. This can take up to a minute...");
console.log(await selfTest(ctx));
await ctx.bridge.close();
return 0;
}
default:
console.log(USAGE);
return command ? 1 : 0;
}
}
if (process.argv.length > 2) {
runCli(process.argv.slice(2)).then(
(code) => process.exit(code),
(err) => {
console.error(err);
process.exit(1);
}
);
} else {
runServer().catch((err) => {
process.stderr.write(`[simpleSearch] fatal: ${(err as Error).stack ?? err}\n`);
process.exit(1);
});
}