import { dirname, join } from 'node:path'; import { mkdirSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { SmartRouter, type HttpClient } from './fetch/router.js'; import { MultiBrowserPool } from './fetch/browser-pool.js'; import { closeDaemonBrowser } from './fetch/playwright-tier.js'; import { httpFetch } from './fetch/http-client.js'; import { initDatabase, closeDatabase } from './cache/db.js'; import { handleFetch } from './tools/fetch.js'; import { handleSearch } from './tools/search.js'; import { buildSearchContentBlocks } from './server/search-response.js'; import { handleCrawl } from './tools/crawl.js'; import { handleCache } from './tools/cache.js'; import { handleExtract } from './tools/extract.js'; import { handleFindSimilar } from './tools/find-similar.js'; import { handleResearch } from './tools/research.js'; import { handleAgent } from './tools/agent.js'; import { handleDiff } from './tools/diff.js'; import { handleWatch } from './tools/watch.js'; import { scheduleOverdueCheck } from './watch/scheduler.js'; import type { SamplingCapableServer } from './search/sampling.js'; import { SearxngClient } from './search/searxng.js'; import { DuckDuckGoEngine } from './search/engines/duckduckgo.js'; import { BingEngine } from './search/engines/bing.js'; import { resolveSearchBackend, getBootstrapState } from './searxng/bootstrap.js'; import { searxngConfigured, searxngBackendAvailable } from './searxng/enabled.js'; import { SearxngProcess } from './searxng/process.js'; import { DockerSearxng } from './searxng/docker.js'; import { BackendStatus } from './server/backend-status.js'; import { maybeEagerWarmup, warmEngines } from './server/warmup-on-start.js'; import { getEmbeddingService, resetEmbeddingService } from './embedding/embed.js'; import { getConfig } from './config.js'; import { createLogger } from './logger.js'; import { WIGOLO_INSTRUCTIONS, WIGOLO_INSTRUCTIONS_FULL, WIGOLO_DOCS_URI, TOOL_DESCRIPTIONS, } from './instructions.js'; import { FETCH_TOOL_SCHEMA, SEARCH_TOOL_SCHEMA, CRAWL_TOOL_SCHEMA, CACHE_TOOL_SCHEMA, EXTRACT_TOOL_SCHEMA, FIND_SIMILAR_TOOL_SCHEMA, RESEARCH_TOOL_SCHEMA, AGENT_TOOL_SCHEMA, DIFF_TOOL_SCHEMA, WATCH_TOOL_SCHEMA, } from './server/tool-schemas.js'; import { loadPlugins } from './plugins/loader.js'; import { PluginRegistry } from './plugins/registry.js'; import { registerExtractor } from './extraction/pipeline.js'; import type { FetchInput, SearchInput, SearchEngine, CrawlInput, CacheInput, ExtractInput, FindSimilarInput, ResearchInput, AgentInput, ProgressCallback, WatchJobInput } from './types.js'; const log = createLogger('server'); function readPackageVersion(): string { try { const here = dirname(fileURLToPath(import.meta.url)); // src/server.ts in dev, dist/server.js in build — both are siblings of package.json const pkgPath = join(here, '..', 'package.json'); const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string }; return pkg.version ?? '0.0.0'; } catch { return '0.0.0'; } } const SERVER_VERSION = readPackageVersion(); export interface Subsystems { searchEngines: SearchEngine[]; browserPool: MultiBrowserPool; router: SmartRouter; backendStatus: BackendStatus; pluginRegistry: PluginRegistry; shutdown: () => Promise; bootstrapSearxng: () => Promise; } export async function initSubsystems(): Promise { const config = getConfig(); mkdirSync(config.dataDir, { recursive: true }); initDatabase(join(config.dataDir, 'wigolo.db')); // Initialize embedding service: provisions the vector store, runs the // legacy-embedding migration, and surfaces sqlite-vec failures. It does NOT // load the embedding model — that happens lazily on first embed/find_similar // via ensureProviderReady(), keeping idle footprint low (D2). try { await getEmbeddingService().init(); } catch (err) { log.warn('embedding service init failed, find_similar will run without embedding path', { error: String(err), }); } const httpClient: HttpClient = { fetch: (url, options) => httpFetch(url, options), }; const browserPool = new MultiBrowserPool({ browserTypes: config.browserTypes, selectionStrategy: 'round-robin', }); const router = new SmartRouter(httpClient, browserPool); const backendStatus = new BackendStatus(); const searchEngines: SearchEngine[] = [ new BingEngine(), new DuckDuckGoEngine(), ]; // Load plugins from ~/.wigolo/plugins/ const pluginRegistry = new PluginRegistry(); try { const pluginResult = await loadPlugins(); for (const ext of pluginResult.extractors) { pluginRegistry.registerExtractor(ext, ext.name); registerExtractor(ext); } for (const eng of pluginResult.searchEngines) { pluginRegistry.registerSearchEngine(eng, eng.name); searchEngines.push(eng); } if (pluginResult.errors.length > 0) { log.warn('some plugins failed to load', { errors: pluginResult.errors.map(e => `${e.pluginName}: ${e.message}`), }); } if (pluginResult.loaded.length > 0) { log.info('plugins loaded', { count: pluginResult.loaded.length, names: pluginResult.loaded.map(p => p.name), }); } } catch (err) { log.error('plugin loading failed', { error: String(err) }); } let searxngProcess: SearxngProcess | null = null; let dockerSearxng: DockerSearxng | null = null; let searxngBootstrap: Promise | null = null; async function bootstrapSearxng(): Promise { // D1: the search-engine sidecar is opt-in. On the default core backend with // no external URL we do ZERO sidecar activity — no backend resolution (which // both probes runtimes and writes state files), no port probe, no process. if (!searxngConfigured(config)) { return; } // Configured (searxng/hybrid backend or external URL) but resolution never // installs implicitly. When no usable endpoint exists yet, tell the user how // to opt into the install rather than downloading behind their back. if (!searxngBackendAvailable(config)) { backendStatus.markUnhealthy( 'search engine sidecar not installed — set WIGOLO_SEARXNG_URL to point at an ' + 'external instance, or run `wigolo warmup --searxng` to install it', ); log.warn( 'search engine sidecar configured but not installed; using fallback engines. ' + 'Set WIGOLO_SEARXNG_URL or run `wigolo warmup --searxng`', ); return; } try { const initialState = getBootstrapState(config.dataDir); if (!config.searxngUrl && initialState?.status !== 'ready') { backendStatus.markBootstrapping(); } const backend = await resolveSearchBackend(); if (backend.type === 'external' && backend.url) { searchEngines.unshift(new SearxngClient(backend.url)); backendStatus.markHealthy(); log.info('using external search engine', { url: backend.url }); return; } if (backend.type === 'native' && backend.searxngPath) { // We only reach here when the sidecar is already installed // (searxngBackendAvailable gated this). The installer is never invoked // implicitly — it lives behind `wigolo warmup --searxng`. const postBootstrapState = getBootstrapState(config.dataDir); if (postBootstrapState?.status === 'ready') { searxngProcess = new SearxngProcess(backend.searxngPath, config.dataDir, { onUnhealthy: (reason) => { backendStatus.markUnhealthy(reason); const idx = searchEngines.findIndex(e => e.name === 'searxng'); if (idx >= 0) searchEngines.splice(idx, 1); log.warn('search engine marked unhealthy', { reason }); }, onHealthy: () => { const url = searxngProcess?.getUrl(); if (!url) return; backendStatus.markHealthy(); if (!searchEngines.some(e => e.name === 'searxng')) { searchEngines.unshift(new SearxngClient(url)); } log.info('search engine recovered'); }, }); const url = await searxngProcess.start(); if (url) { searchEngines.unshift(new SearxngClient(url)); backendStatus.markHealthy(); log.info('search engine ready', { url }); } else { log.warn('search engine failed to start, using fallback scraping'); backendStatus.markUnhealthy('search engine process failed to start'); } } return; } if (backend.type === 'docker') { dockerSearxng = new DockerSearxng(); const url = await dockerSearxng.start(); if (url) { searchEngines.unshift(new SearxngClient(url)); backendStatus.markHealthy(); log.info('search engine (docker) ready', { url }); } else { log.warn('search engine (docker) failed to start, using fallback scraping'); backendStatus.markUnhealthy('search engine (docker) failed to start'); } } if (backend.type === 'scraping') { const state = getBootstrapState(config.dataDir); const reason = state?.lastError?.message ?? state?.error ?? 'no search engine backend available'; backendStatus.markUnhealthy(reason); } } catch (err) { log.warn('background backend setup failed', { error: String(err) }); backendStatus.markUnhealthy(`backend setup failed: ${String(err)}`); } } async function shutdown(): Promise { log.info('Shutting down'); if (searxngBootstrap) { await Promise.race([ searxngBootstrap.catch(() => {}), new Promise((r) => setTimeout(r, 2000)), ]); } if (searxngProcess) await searxngProcess.stop(); if (dockerSearxng) await dockerSearxng.stop(); await browserPool.shutdown(); await closeDaemonBrowser().catch((e) => log.debug('closeDaemonBrowser failed', { error: e instanceof Error ? e.message : String(e) })); resetEmbeddingService(); closeDatabase(); } return { searchEngines, browserPool, router, backendStatus, pluginRegistry, shutdown, bootstrapSearxng: () => { searxngBootstrap = bootstrapSearxng(); return searxngBootstrap; }, }; } export function createMcpServer(subsystems: Subsystems): Server { const { searchEngines, router, backendStatus } = subsystems; const server = new Server( { name: 'wigolo', version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} }, instructions: WIGOLO_INSTRUCTIONS, }, ); server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [ { uri: WIGOLO_DOCS_URI, name: 'Wigolo usage guide', description: 'Routing tables, performance budgets, auth flows, and other detail trimmed from the per-session instructions.', mimeType: 'text/markdown', }, ], })); server.setRequestHandler(ReadResourceRequestSchema, async (request) => { if (request.params.uri !== WIGOLO_DOCS_URI) { throw new Error(`Unknown resource: ${request.params.uri}`); } return { contents: [ { uri: WIGOLO_DOCS_URI, mimeType: 'text/markdown', text: WIGOLO_INSTRUCTIONS_FULL, }, ], }; }); // Capability hints per MCP `tools/list`. Hosts use these to decide whether a // call needs a permission prompt, so each one describes the WIDEST behaviour // its tool can reach, not the common case. // // Read-only tools still populate the local content cache. That store is an // implementation detail rather than caller-visible state, which is why they // stay `readOnlyHint: true` while `cache` — the tool that exposes the store // directly — does not. // // `idempotentHint` is inert wherever `readOnlyHint` is true (spec: "meaningful // only when readOnlyHint == false"), so those tools carry `true` for // consistency rather than as a claim about output stability. server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'fetch', description: TOOL_DESCRIPTIONS.fetch, inputSchema: FETCH_TOOL_SCHEMA, // Not read-only: `actions` accepts `click` and `type`, which run as live // Playwright interactions on the target page (tool-schemas.ts), so a // caller can submit a form or trigger navigation. The hints cover the // widest reachable behaviour, and a click on an arbitrary page can // destroy remote state, so `destructiveHint` is true even though the // no-actions path — the common one — only reads. annotations: { title: 'Fetch a page', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, }, }, { name: 'search', description: TOOL_DESCRIPTIONS.search, inputSchema: SEARCH_TOOL_SCHEMA, annotations: { title: 'Web search', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: 'crawl', description: TOOL_DESCRIPTIONS.crawl, inputSchema: CRAWL_TOOL_SCHEMA, annotations: { title: 'Crawl a site', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: 'cache', description: TOOL_DESCRIPTIONS.cache, inputSchema: CACHE_TOOL_SCHEMA, // `clear` deletes rows; `check_changes` re-fetches every matching URL // over the network, so this is not a closed-world tool either. annotations: { title: 'Search or clear the local cache', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, }, }, { name: 'extract', description: TOOL_DESCRIPTIONS.extract, inputSchema: EXTRACT_TOOL_SCHEMA, annotations: { title: 'Extract structured data', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: 'find_similar', description: TOOL_DESCRIPTIONS.find_similar, inputSchema: FIND_SIMILAR_TOOL_SCHEMA, annotations: { title: 'Find similar pages', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: 'research', description: TOOL_DESCRIPTIONS.research, inputSchema: RESEARCH_TOOL_SCHEMA, annotations: { title: 'Deep research', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: 'agent', description: TOOL_DESCRIPTIONS.agent, inputSchema: AGENT_TOOL_SCHEMA, annotations: { title: 'Autonomous data gathering', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: 'diff', description: TOOL_DESCRIPTIONS.diff, inputSchema: DIFF_TOOL_SCHEMA, annotations: { title: 'Diff two versions', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, { name: 'watch', description: TOOL_DESCRIPTIONS.watch, inputSchema: WATCH_TOOL_SCHEMA, // `create`/`delete`/`pause`/`resume` mutate the persistent job store. annotations: { title: 'Watch a URL for changes', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, }, }, ], })); server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { const { name, arguments: args } = request.params; // Lazy-execution hook for the `watch` tool. Every non-watch tool call // gives us a chance to run overdue watch jobs in the background. This // is intentional: wigolo has no daemon — checks only fire when the // server is doing other work. `scheduleOverdueCheck` defers via // setImmediate and swallows errors, so it never blocks or fails the // primary tool call. if (name !== 'watch') { scheduleOverdueCheck(router); } // If the client supplied a progressToken in request._meta, build a // callback that forwards progress updates as notifications/progress. // Used by stream_answer to emit pipeline-phase progress. const meta = (request.params as { _meta?: { progressToken?: string | number } })._meta; const progressToken = meta?.progressToken; const onProgress: ProgressCallback | undefined = progressToken !== undefined && extra && typeof extra.sendNotification === 'function' ? async (update) => { try { await extra.sendNotification({ method: 'notifications/progress', params: { progressToken, progress: update.progress, total: update.total, message: update.message, }, } as Parameters[0]); } catch (err) { log.debug('sendNotification failed', { error: String(err) }); } } : undefined; if (name === 'fetch') { const input = (args ?? {}) as unknown as FetchInput; const r = await handleFetch(input, router); if (!r.ok) { return { content: [{ type: 'text', text: JSON.stringify({ error: r.error, error_reason: r.error_reason, stage: r.stage, ...(r.hint ? { hint: r.hint } : {}) }, null, 2) }], isError: true, }; } return { content: [{ type: 'text', text: JSON.stringify(r.data, null, 2) }], isError: false, }; } if (name === 'search') { const input = (args ?? {}) as unknown as SearchInput; const samplingServer = server as unknown as SamplingCapableServer; const r = await handleSearch(input, searchEngines, router, backendStatus, samplingServer, onProgress); if (!r.ok) { return { content: [{ type: 'text', text: JSON.stringify({ error: r.error, error_reason: r.error_reason, stage: r.stage, ...(r.hint ? { hint: r.hint } : {}) }, null, 2) }], isError: true, }; } const blocks = buildSearchContentBlocks(input, r.data); return { content: blocks, isError: !!r.data.error, }; } if (name === 'crawl') { const input = (args ?? {}) as unknown as CrawlInput; const result = await handleCrawl(input, router); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], isError: !!result.error, }; } if (name === 'cache') { const input = (args ?? {}) as unknown as CacheInput; const result = await handleCache(input, router); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], isError: !!result.error, }; } if (name === 'extract') { const input = (args ?? {}) as unknown as ExtractInput; const r = await handleExtract(input, router); if (!r.ok) { return { content: [{ type: 'text', text: JSON.stringify({ error: r.error, error_reason: r.error_reason, stage: r.stage, ...(r.hint ? { hint: r.hint } : {}) }, null, 2) }], isError: true, }; } return { content: [{ type: 'text', text: JSON.stringify(r.data, null, 2) }], isError: false, }; } if (name === 'find_similar') { const input = (args ?? {}) as unknown as FindSimilarInput; const r = await handleFindSimilar(input, searchEngines, router, backendStatus); if (!r.ok) { return { content: [{ type: 'text', text: JSON.stringify({ error: r.error, error_reason: r.error_reason, stage: r.stage, ...(r.hint ? { hint: r.hint } : {}) }, null, 2) }], isError: true, }; } return { content: [{ type: 'text', text: JSON.stringify(r.data, null, 2) }], isError: false, }; } if (name === 'research') { const input = (args ?? {}) as unknown as ResearchInput; const samplingServer = server as unknown as SamplingCapableServer; const r = await handleResearch(input, searchEngines, router, backendStatus, samplingServer); if (!r.ok) { return { content: [{ type: 'text', text: JSON.stringify({ error: r.error, error_reason: r.error_reason, stage: r.stage, ...(r.hint ? { hint: r.hint } : {}) }, null, 2) }], isError: true, }; } return { content: [{ type: 'text', text: JSON.stringify(r.data, null, 2) }], isError: false, }; } if (name === 'agent') { const input = (args ?? {}) as unknown as AgentInput; const samplingServer = server as unknown as SamplingCapableServer; const r = await handleAgent(input, searchEngines, router, backendStatus, samplingServer, onProgress); if (!r.ok) { return { content: [{ type: 'text', text: JSON.stringify({ error: r.error, error_reason: r.error_reason, stage: r.stage, ...(r.hint ? { hint: r.hint } : {}) }, null, 2) }], isError: true, }; } return { content: [{ type: 'text', text: JSON.stringify(r.data, null, 2) }], isError: false, }; } // `diff` compares cached/inline content and needs no router; `watch` // takes the router because it fetches and diffs on each check. if (name === 'diff') { const input = (args ?? {}) as Record; const r = await handleDiff(input); return { content: [{ type: 'text', text: JSON.stringify(r.ok ? r.data : { error: r.error, error_reason: r.error_reason, stage: r.stage }, null, 2) }], isError: !r.ok, }; } if (name === 'watch') { const input = (args ?? {}) as unknown as WatchJobInput; const r = await handleWatch(input, router); return { content: [{ type: 'text', text: JSON.stringify(r.ok ? r.data : { error: r.error, error_reason: r.error_reason, stage: r.stage, ...((r as { hint?: string }).hint ? { hint: (r as { hint?: string }).hint } : {}) }, null, 2) }], isError: !r.ok, }; } return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true, }; }); return server; } export async function startServer(): Promise { const subs = await initSubsystems(); const server = createMcpServer(subs); const transport = new StdioServerTransport(); await server.connect(transport); log.info('MCP server started'); maybeEagerWarmup(); warmEngines(); subs.bootstrapSearxng().catch((err) => { log.warn('search engine bootstrap failed', { error: String(err) }); }); const shutdown = async () => { await subs.shutdown(); await server.close(); process.exit(0); }; process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); }