src / tools.ts
src / tools.ts
// The tools simpleSearch offers a model, shared by the MCP server (main.ts) and the LM Studio
// plugin (index.ts), so both describe and run them the same way.
import { z } from "zod";
import { KNOWN_ENGINES } from "./config";
import { Ctx, inspectLayout, proposeRules, readWebpage, selfTest, webSearch } from "./ops";
import { annotate } from "./research";
export interface ToolSpec<S extends z.ZodRawShape = z.ZodRawShape> {
name: string;
description: string;
shape: S;
run: (args: z.infer<z.ZodObject<S>>) => Promise<string>;
}
const spec = <S extends z.ZodRawShape>(s: ToolSpec<S>): ToolSpec => s as unknown as ToolSpec;
export function toolSpecs(ctx: Ctx): ToolSpec[] {
const engineEnum = z.enum(KNOWN_ENGINES);
// Timing for each search and page read: start, how many other calls were running at that
// moment, and duration with the result status. Counts and times only; never a query or address.
let inFlight = 0;
const timed = async (kind: string, fn: () => Promise<string>): Promise<string> => {
const n = ++ctx.calls;
const others = inFlight++;
const started = Date.now();
ctx.log(`call ${n} (${kind}) started, ${others} other call(s) running`);
try {
const out = await fn();
const status = /^status: (\w+)/.exec(out)?.[1] ?? "?";
ctx.log(`call ${n} (${kind}) finished in ${((Date.now() - started) / 1000).toFixed(1)} s: ${status}`);
return out;
} finally {
inFlight--;
}
};
const goalField = z
.string()
.optional()
.describe("The user's question or the task this call serves, in one sentence. Pass the same goal on every call for one task.");
return [
spec({
name: "web_search",
description:
"Search the web through the user's browser. Returns a status line and a numbered list of results with title, URL and description. Read promising results with read_webpage before relying on them.",
shape: {
query: z.string().describe("What to search for, in plain words."),
goal: goalField,
engine: engineEnum.optional().describe("Only use this engine. Leave empty to use the configured engines in order."),
},
run: async ({ query, goal, engine }) => {
const r = ctx.research.resolve(goal, query);
if (r && ctx.research.isBlocked(r)) return ctx.research.refuse(r, "search");
return annotate(await timed("search", () => webSearch(ctx, query, engine)), r, ctx.research, "search");
},
}),
spec({
name: "read_webpage",
description:
"Open a URL in the user's browser and return its text. With a goal, long pages come back as the passages most relevant to it plus an outline; pass full: true for the whole text. Long text comes in parts; to continue, call again with the offset given in the previous response.",
shape: {
url: z.string().describe("Full URL starting with https:// or http://"),
goal: goalField,
full: z.boolean().optional().describe("Return the whole text in order instead of the passages most relevant to the goal."),
offset: z.number().int().min(0).optional().describe("Character position to continue from. Leave empty for the start of the page."),
},
run: async ({ url, goal, full, offset }) => {
const r = ctx.research.resolve(goal);
if (r && ctx.research.isBlocked(r)) return ctx.research.refuse(r, "page");
// Only a goal the model stated focuses a page; a task named after a search query is too rough.
const out = await timed("read", () => readWebpage(ctx, url, offset ?? 0, { goal: ctx.research.focusGoal(r), full }));
return annotate(out, r, ctx.research, "page");
},
}),
spec({
name: "search_self_test",
description:
"Check the browser connection and every search engine, and report what works. Use when searches keep failing or when the user asks whether search is working.",
shape: {},
run: () => selfTest(ctx),
}),
spec({
name: "inspect_search_layout",
description:
"Use only after a search reported parser_failed, or when the user asks to repair a search engine. Loads a fresh results page from that engine and returns a sample of its structure for writing new parser rules.",
shape: { engine: engineEnum },
run: ({ engine }) => inspectLayout(ctx, engine),
}),
spec({
name: "propose_parser_rules",
description:
"Submit replacement CSS selectors for one search engine's result parser, based on inspect_search_layout. They are tested against the sample page before being accepted.",
shape: {
engine: engineEnum,
container: z.string().describe("Selector for the element that wraps one whole search result."),
title: z.string().describe("Selector, inside the container, for the result's title."),
link: z.string().describe("Selector, inside the container, for the link to the result."),
snippet: z.string().optional().describe("Selector, inside the container, for the result's description."),
exclude: z.string().optional().describe("Containers matching this selector are skipped, for example ads."),
url_param: z.string().optional().describe("If result links go through a redirect on the engine's site, the query parameter holding the real URL."),
},
run: ({ engine, ...rules }) => proposeRules(ctx, engine, rules),
}),
];
}
// The tools simpleSearch offers a model, shared by the MCP server (main.ts) and the LM Studio
// plugin (index.ts), so both describe and run them the same way.
import { z } from "zod";
import { KNOWN_ENGINES } from "./config";
import { Ctx, inspectLayout, proposeRules, readWebpage, selfTest, webSearch } from "./ops";
import { annotate } from "./research";
export interface ToolSpec<S extends z.ZodRawShape = z.ZodRawShape> {
name: string;
description: string;
shape: S;
run: (args: z.infer<z.ZodObject<S>>) => Promise<string>;
}
const spec = <S extends z.ZodRawShape>(s: ToolSpec<S>): ToolSpec => s as unknown as ToolSpec;
export function toolSpecs(ctx: Ctx): ToolSpec[] {
const engineEnum = z.enum(KNOWN_ENGINES);
// Timing for each search and page read: start, how many other calls were running at that
// moment, and duration with the result status. Counts and times only; never a query or address.
let inFlight = 0;
const timed = async (kind: string, fn: () => Promise<string>): Promise<string> => {
const n = ++ctx.calls;
const others = inFlight++;
const started = Date.now();
ctx.log(`call ${n} (${kind}) started, ${others} other call(s) running`);
try {
const out = await fn();
const status = /^status: (\w+)/.exec(out)?.[1] ?? "?";
ctx.log(`call ${n} (${kind}) finished in ${((Date.now() - started) / 1000).toFixed(1)} s: ${status}`);
return out;
} finally {
inFlight--;
}
};
const goalField = z
.string()
.optional()
.describe("The user's question or the task this call serves, in one sentence. Pass the same goal on every call for one task.");
return [
spec({
name: "web_search",
description:
"Search the web through the user's browser. Returns a status line and a numbered list of results with title, URL and description. Read promising results with read_webpage before relying on them.",
shape: {
query: z.string().describe("What to search for, in plain words."),
goal: goalField,
engine: engineEnum.optional().describe("Only use this engine. Leave empty to use the configured engines in order."),
},
run: async ({ query, goal, engine }) => {
const r = ctx.research.resolve(goal, query);
if (r && ctx.research.isBlocked(r)) return ctx.research.refuse(r, "search");
return annotate(await timed("search", () => webSearch(ctx, query, engine)), r, ctx.research, "search");
},
}),
spec({
name: "read_webpage",
description:
"Open a URL in the user's browser and return its text. With a goal, long pages come back as the passages most relevant to it plus an outline; pass full: true for the whole text. Long text comes in parts; to continue, call again with the offset given in the previous response.",
shape: {
url: z.string().describe("Full URL starting with https:// or http://"),
goal: goalField,
full: z.boolean().optional().describe("Return the whole text in order instead of the passages most relevant to the goal."),
offset: z.number().int().min(0).optional().describe("Character position to continue from. Leave empty for the start of the page."),
},
run: async ({ url, goal, full, offset }) => {
const r = ctx.research.resolve(goal);
if (r && ctx.research.isBlocked(r)) return ctx.research.refuse(r, "page");
// Only a goal the model stated focuses a page; a task named after a search query is too rough.
const out = await timed("read", () => readWebpage(ctx, url, offset ?? 0, { goal: ctx.research.focusGoal(r), full }));
return annotate(out, r, ctx.research, "page");
},
}),
spec({
name: "search_self_test",
description:
"Check the browser connection and every search engine, and report what works. Use when searches keep failing or when the user asks whether search is working.",
shape: {},
run: () => selfTest(ctx),
}),
spec({
name: "inspect_search_layout",
description:
"Use only after a search reported parser_failed, or when the user asks to repair a search engine. Loads a fresh results page from that engine and returns a sample of its structure for writing new parser rules.",
shape: { engine: engineEnum },
run: ({ engine }) => inspectLayout(ctx, engine),
}),
spec({
name: "propose_parser_rules",
description:
"Submit replacement CSS selectors for one search engine's result parser, based on inspect_search_layout. They are tested against the sample page before being accepted.",
shape: {
engine: engineEnum,
container: z.string().describe("Selector for the element that wraps one whole search result."),
title: z.string().describe("Selector, inside the container, for the result's title."),
link: z.string().describe("Selector, inside the container, for the link to the result."),
snippet: z.string().optional().describe("Selector, inside the container, for the result's description."),
exclude: z.string().optional().describe("Containers matching this selector are skipped, for example ads."),
url_param: z.string().optional().describe("If result links go through a redirect on the engine's site, the query parameter holding the real URL."),
},
run: ({ engine, ...rules }) => proposeRules(ctx, engine, rules),
}),
];
}