@kirosnn/mosaic 0.73.0 → 0.75.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.
@@ -0,0 +1,151 @@
1
+ import { chromium, firefox, webkit, type Browser, type BrowserContext, type Page } from 'playwright';
2
+ import { LaunchConfig, ContextConfig } from './types.js';
3
+
4
+ const browserTypes = { chromium, firefox, webkit };
5
+
6
+ let browser: Browser | null = null;
7
+ let context: BrowserContext | null = null;
8
+
9
+ export const launchConfig: LaunchConfig = {
10
+ browserType: 'chromium',
11
+ headless: true,
12
+ args: [
13
+ '--disable-blink-features=AutomationControlled',
14
+ '--disable-infobars',
15
+ '--no-sandbox',
16
+ '--disable-setuid-sandbox',
17
+ '--ignore-certificate-errors',
18
+ '--disable-features=IsolateOrigins,site-per-process',
19
+ ],
20
+ };
21
+
22
+ const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
23
+ const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
24
+ const DEFAULT_PAGE_TIMEOUT = 30000;
25
+ const DEFAULT_NAV_TIMEOUT = 60000;
26
+
27
+ export const contextConfig: ContextConfig = {
28
+ userAgent: DEFAULT_USER_AGENT,
29
+ viewport: DEFAULT_VIEWPORT,
30
+ };
31
+
32
+ const tabs = new Map<string, Page>();
33
+ const tabOrder: string[] = [];
34
+ const pageToId = new WeakMap<Page, string>();
35
+ let currentTabId: string | null = null;
36
+ let tabSeq = 0;
37
+
38
+ function configurePage(page: Page): void {
39
+ page.setDefaultTimeout(DEFAULT_PAGE_TIMEOUT);
40
+ page.setDefaultNavigationTimeout(DEFAULT_NAV_TIMEOUT);
41
+ }
42
+
43
+ export function registerPage(page: Page): string {
44
+ const existing = pageToId.get(page);
45
+ if (existing) return existing;
46
+ configurePage(page);
47
+ const id = `tab_${++tabSeq}`;
48
+ pageToId.set(page, id);
49
+ tabs.set(id, page);
50
+ tabOrder.push(id);
51
+ currentTabId = id;
52
+ page.on('close', () => {
53
+ tabs.delete(id);
54
+ const index = tabOrder.indexOf(id);
55
+ if (index >= 0) tabOrder.splice(index, 1);
56
+ if (currentTabId === id) {
57
+ currentTabId = tabOrder.length > 0 ? tabOrder[tabOrder.length - 1]! : null;
58
+ }
59
+ });
60
+ return id;
61
+ }
62
+
63
+ export async function ensureBrowser(): Promise<Browser> {
64
+ if (browser) return browser;
65
+ const launcher = browserTypes[launchConfig.browserType];
66
+ browser = await launcher.launch({
67
+ headless: launchConfig.headless,
68
+ channel: launchConfig.channel,
69
+ executablePath: launchConfig.executablePath,
70
+ args: [
71
+ ...launchConfig.args,
72
+ ...(launchConfig.args.length > 0 ? [] : [])
73
+ ],
74
+ });
75
+ return browser;
76
+ }
77
+
78
+ export async function ensureContext(): Promise<BrowserContext> {
79
+ if (context) return context;
80
+ const b = await ensureBrowser();
81
+ context = await b.newContext({
82
+ userAgent: contextConfig.userAgent,
83
+ viewport: contextConfig.viewport === null ? null : contextConfig.viewport,
84
+ extraHTTPHeaders: contextConfig.extraHTTPHeaders,
85
+ locale: 'en-US',
86
+ timezoneId: contextConfig.timezoneId,
87
+ deviceScaleFactor: 1,
88
+ isMobile: false,
89
+ hasTouch: false,
90
+ javaScriptEnabled: true,
91
+ });
92
+
93
+ await context.addInitScript(() => {
94
+ Object.defineProperty(navigator, 'webdriver', {
95
+ get: () => undefined,
96
+ });
97
+
98
+ if (!(window as any).chrome) {
99
+ (window as any).chrome = {
100
+ runtime: {},
101
+ loadTimes: function () { },
102
+ csi: function () { },
103
+ app: {},
104
+ };
105
+ }
106
+
107
+ if (navigator.plugins.length === 0) {
108
+ Object.defineProperty(navigator, 'plugins', {
109
+ get: () => {
110
+ return [
111
+ { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
112
+ { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: 'Portable Document Format' },
113
+ { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' }
114
+ ];
115
+ },
116
+ });
117
+ }
118
+
119
+ Object.defineProperty(navigator, 'languages', {
120
+ get: () => ['en-US', 'en'],
121
+ });
122
+ });
123
+
124
+ context.on('page', page => {
125
+ registerPage(page);
126
+ });
127
+ return context;
128
+ }
129
+
130
+ export async function ensurePage(tabId?: string): Promise<{ page: Page; tabId: string }> {
131
+ const ctx = await ensureContext();
132
+ if (tabId) {
133
+ const existing = tabs.get(tabId);
134
+ if (!existing) {
135
+ throw new Error(`Tab not found: ${tabId}`);
136
+ }
137
+ currentTabId = tabId;
138
+ return { page: existing, tabId };
139
+ }
140
+ if (currentTabId) {
141
+ const current = tabs.get(currentTabId);
142
+ if (current) return { page: current, tabId: currentTabId };
143
+ }
144
+ const page = await ctx.newPage();
145
+ const id = registerPage(page);
146
+ return { page, tabId: id };
147
+ }
148
+
149
+ export function getTabs() {
150
+ return tabs;
151
+ }
@@ -0,0 +1,23 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { ensureContext } from './browser.js';
4
+ import { registerTools } from './tools.js';
5
+
6
+ const server = new McpServer({ name: 'navigation', version: '1.0.0' });
7
+
8
+ registerTools(server);
9
+
10
+ async function main(): Promise<void> {
11
+ const transport = new StdioServerTransport();
12
+ await server.connect(transport);
13
+ try {
14
+ await ensureContext();
15
+ } catch {
16
+ // Browser will be launched on first tool call if pre-launch fails
17
+ }
18
+ }
19
+
20
+ main().catch(error => {
21
+ console.error(error);
22
+ process.exit(1);
23
+ });
@@ -0,0 +1,263 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import type { Page } from 'playwright';
4
+ import { ensureContext, ensurePage, registerPage } from './browser.js';
5
+ import { randomDelay } from './utils.js';
6
+
7
+ type SearchEngine = 'duckduckgo' | 'google' | 'bing';
8
+
9
+ interface SearchResult {
10
+ text: string;
11
+ href: string;
12
+ snippet: string;
13
+ }
14
+
15
+ interface EngineConfig {
16
+ buildUrl: (query: string) => string;
17
+ extract: (page: Page, limit: number) => Promise<SearchResult[]>;
18
+ }
19
+
20
+ async function resolveRedirects(page: Page, results: SearchResult[]): Promise<SearchResult[]> {
21
+ return page.evaluate((results) => {
22
+ return results.map(r => {
23
+ try {
24
+ const url = new URL(r.href);
25
+ if (url.hostname.includes('bing.com') && url.pathname === '/ck/a') {
26
+ const u = url.searchParams.get('u');
27
+ if (u) {
28
+ const decoded = atob(u.replace(/^a1/, ''));
29
+ if (decoded.startsWith('http')) return { ...r, href: decoded };
30
+ }
31
+ }
32
+ } catch { }
33
+ return r;
34
+ });
35
+ }, results);
36
+ }
37
+
38
+ const engines: Record<SearchEngine, EngineConfig> = {
39
+ bing: {
40
+ buildUrl: (q) => `https://www.bing.com/search?q=${encodeURIComponent(q)}`,
41
+ extract: async (page, limit) => {
42
+ const raw = await page.evaluate((limit) => {
43
+ const items: { text: string; href: string; snippet: string }[] = [];
44
+ const results = document.querySelectorAll('.b_algo');
45
+
46
+ for (const el of Array.from(results)) {
47
+ if (items.length >= limit) break;
48
+
49
+ const linkEl = el.querySelector('h2 a');
50
+ const snippetEl = el.querySelector('.b_caption p, p');
51
+
52
+ const text = linkEl?.textContent?.trim();
53
+ const href = linkEl?.getAttribute('href');
54
+ const snippet = snippetEl?.textContent?.trim() || '';
55
+
56
+ if (text && href && href.startsWith('http')) {
57
+ items.push({ text, href, snippet });
58
+ }
59
+ }
60
+ return items;
61
+ }, limit);
62
+ return resolveRedirects(page, raw);
63
+ },
64
+ },
65
+ duckduckgo: {
66
+ buildUrl: (q) => `https://duckduckgo.com/?q=${encodeURIComponent(q)}`,
67
+ extract: async (page, limit) => {
68
+ return page.evaluate((limit) => {
69
+ const items: { text: string; href: string; snippet: string }[] = [];
70
+ const selectors = ['article[data-testid="result"]', '.result', '.web-result', '.results .result__body'];
71
+
72
+ for (const selector of selectors) {
73
+ const elements = document.querySelectorAll(selector);
74
+ if (elements.length === 0) continue;
75
+
76
+ for (const el of Array.from(elements)) {
77
+ if (items.length >= limit) break;
78
+
79
+ const titleEl = el.querySelector('h2 a, a[data-testid="result-title-a"], .result__a');
80
+ const snippetEl = el.querySelector('[data-result="snippet"], .result__snippet, span');
81
+
82
+ const text = titleEl?.textContent?.trim();
83
+ const href = titleEl?.getAttribute('href');
84
+ const snippet = snippetEl?.textContent?.trim() || '';
85
+
86
+ if (text && href && href.startsWith('http')) {
87
+ items.push({ text, href, snippet });
88
+ }
89
+ }
90
+ if (items.length > 0) break;
91
+ }
92
+ return items;
93
+ }, limit);
94
+ },
95
+ },
96
+ google: {
97
+ buildUrl: (q) => `https://www.google.com/search?q=${encodeURIComponent(q)}&udm=14`,
98
+ extract: async (page, limit) => {
99
+ return page.evaluate((limit) => {
100
+ const items: { text: string; href: string; snippet: string }[] = [];
101
+ const results = document.querySelectorAll('#search .g');
102
+
103
+ for (const el of Array.from(results)) {
104
+ if (items.length >= limit) break;
105
+
106
+ const titleEl = el.querySelector('h3');
107
+ const linkEl = el.querySelector('a');
108
+ const snippetEl = el.querySelector('[style*="-webkit-line-clamp"], .VwiC3b, div[style*="line-height"]');
109
+
110
+ const text = titleEl?.textContent?.trim();
111
+ const href = linkEl?.getAttribute('href');
112
+ const snippet = snippetEl?.textContent?.trim() || '';
113
+
114
+ if (text && href && href.startsWith('http')) {
115
+ items.push({ text, href, snippet });
116
+ }
117
+ }
118
+ return items;
119
+ }, limit);
120
+ },
121
+ },
122
+ };
123
+
124
+ async function extractFallbackResults(page: Page, limit: number): Promise<SearchResult[]> {
125
+ return page.evaluate((limit) => {
126
+ const items: { text: string; href: string; snippet: string }[] = [];
127
+ const seen = new Set<string>();
128
+ const links = document.querySelectorAll('a[href^="http"]');
129
+
130
+ for (const link of Array.from(links)) {
131
+ if (items.length >= limit) break;
132
+
133
+ const href = link.getAttribute('href');
134
+ if (!href || seen.has(href)) continue;
135
+
136
+ try {
137
+ const host = new URL(href).hostname;
138
+ if (['google.', 'bing.', 'duckduckgo.', 'yahoo.'].some(d => host.includes(d))) continue;
139
+ } catch { continue; }
140
+
141
+ const text = link.textContent?.trim();
142
+ if (!text || text.length < 3) continue;
143
+
144
+ seen.add(href);
145
+ const parent = link.closest('div, li, article, section');
146
+ const snippet = parent?.textContent?.trim().slice(0, 200) || '';
147
+
148
+ items.push({ text, href, snippet });
149
+ }
150
+ return items;
151
+ }, limit);
152
+ }
153
+
154
+ async function dismissConsentDialogs(page: Page): Promise<void> {
155
+ try {
156
+ const consentButton = page.locator(
157
+ 'button:has-text("Tout refuser"), button:has-text("Reject all"), ' +
158
+ 'button:has-text("Alle ablehnen"), button:has-text("Accept"), ' +
159
+ 'button:has-text("I agree"), button:has-text("Accepter")'
160
+ ).first();
161
+ if (await consentButton.isVisible({ timeout: 1500 })) {
162
+ await consentButton.click();
163
+ await page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => { });
164
+ }
165
+ } catch {
166
+ // No consent dialog
167
+ }
168
+ }
169
+
170
+ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
171
+ return Promise.race([
172
+ promise,
173
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)),
174
+ ]);
175
+ }
176
+
177
+ export function registerTools(server: McpServer) {
178
+ server.registerTool('navigation_search', {
179
+ description: 'Search the web and return top results (titles, links, snippets). Defaults to Google. Supports bing, duckduckgo, and google engines.',
180
+ inputSchema: {
181
+ query: z.string(),
182
+ limit: z.number().optional(),
183
+ engine: z.enum(['bing', 'duckduckgo', 'google']).optional(),
184
+ newTab: z.boolean().optional(),
185
+ },
186
+ }, async (args) => {
187
+ const engineName: SearchEngine = args.engine ?? 'google';
188
+ const engine = engines[engineName];
189
+ const limit = args.limit ?? 10;
190
+
191
+ const doSearch = async () => {
192
+ const target = args.newTab ? await ensureContext().then(async ctx => {
193
+ const page = await ctx.newPage();
194
+ const tabId = registerPage(page);
195
+ return { page, tabId };
196
+ }) : await ensurePage();
197
+
198
+ const page = target.page;
199
+ const url = engine.buildUrl(args.query);
200
+
201
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
202
+
203
+ await dismissConsentDialogs(page);
204
+
205
+ await randomDelay(300, 600);
206
+
207
+ let results = await engine.extract(page, limit);
208
+
209
+ if (results.length === 0) {
210
+ results = await extractFallbackResults(page, limit);
211
+ }
212
+
213
+ const currentUrl = page.url();
214
+ const pageTitle = await page.title();
215
+
216
+ if (results.length === 0) {
217
+ return {
218
+ content: [{
219
+ type: 'text' as const,
220
+ text: JSON.stringify({
221
+ tabId: target.tabId,
222
+ engine: engineName,
223
+ url: currentUrl,
224
+ pageTitle,
225
+ resultCount: 0,
226
+ results: [],
227
+ note: 'No results extracted. The page may have shown a CAPTCHA, consent wall, or unexpected layout. Check pageTitle and url for clues.',
228
+ }, null, 2),
229
+ }],
230
+ };
231
+ }
232
+
233
+ return {
234
+ content: [{
235
+ type: 'text' as const,
236
+ text: JSON.stringify({
237
+ tabId: target.tabId,
238
+ engine: engineName,
239
+ url: currentUrl,
240
+ resultCount: results.length,
241
+ results,
242
+ }, null, 2),
243
+ }],
244
+ };
245
+ };
246
+
247
+ try {
248
+ return await withTimeout(doSearch(), 45000, 'navigation_search');
249
+ } catch (error) {
250
+ const msg = error instanceof Error ? error.message : String(error);
251
+ return {
252
+ content: [{
253
+ type: 'text',
254
+ text: JSON.stringify({
255
+ engine: engineName,
256
+ error: `Search failed: ${msg}`,
257
+ }, null, 2),
258
+ }],
259
+ isError: true,
260
+ };
261
+ }
262
+ });
263
+ }
@@ -0,0 +1,17 @@
1
+ export type BrowserTypeName = 'chromium' | 'firefox' | 'webkit';
2
+
3
+ export type LaunchConfig = {
4
+ browserType: BrowserTypeName;
5
+ headless: boolean;
6
+ channel?: string;
7
+ executablePath?: string;
8
+ args: string[];
9
+ };
10
+
11
+ export type ContextConfig = {
12
+ userAgent?: string;
13
+ viewport?: { width: number; height: number } | null;
14
+ extraHTTPHeaders?: Record<string, string>;
15
+ locale?: string;
16
+ timezoneId?: string;
17
+ };
@@ -0,0 +1,20 @@
1
+ import type { Page } from 'playwright';
2
+
3
+ export const randomDelay = (min: number, max: number) => new Promise(resolve => setTimeout(resolve, Math.floor(Math.random() * (max - min + 1)) + min));
4
+
5
+ export async function humanType(page: any, selector: string, text: string) {
6
+ const element = page.locator(selector);
7
+ await element.click();
8
+ for (const char of text) {
9
+ await page.keyboard.type(char, { delay: Math.random() * 100 + 50 });
10
+ }
11
+ }
12
+
13
+ export async function waitForStability(page: Page, timeout = 10000): Promise<void> {
14
+ try {
15
+ await page.waitForLoadState('networkidle', { timeout });
16
+ } catch {
17
+ await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => { });
18
+ await randomDelay(300, 600);
19
+ }
20
+ }