mcpbrowser 0.2.18 → 0.2.21

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,329 @@
1
+ /**
2
+ * UNIT TESTS - Automated tests using mock objects (NO browser required)
3
+ * These tests validate domain pooling logic without opening Chrome
4
+ * Run with: node tests/domain-tab-pooling.test.js
5
+ */
6
+
7
+ // Mock domain pages map and browser
8
+ class MockPage {
9
+ constructor(url) {
10
+ this._url = url;
11
+ this._closed = false;
12
+ this._content = '';
13
+ }
14
+
15
+ url() { return this._url; }
16
+ isClosed() { return this._closed; }
17
+ close() { this._closed = true; }
18
+ async bringToFront() {}
19
+ async goto(url) {
20
+ this._url = url;
21
+ // Simulate eng.ms page with multiple same-domain links
22
+ if (url.includes('eng.ms/docs/products/geneva')) {
23
+ this._content = `
24
+ <html>
25
+ <body>
26
+ <h1>Geneva Documentation</h1>
27
+ <a href="https://eng.ms/docs/products/geneva/getting-started">Getting Started</a>
28
+ <a href="https://eng.ms/docs/products/geneva/configuration">Configuration</a>
29
+ <a href="https://eng.ms/docs/products/geneva/monitoring">Monitoring</a>
30
+ <a href="https://eng.ms/docs/products/geneva/alerts">Alerts</a>
31
+ <a href="https://eng.ms/docs/products/geneva/best-practices">Best Practices</a>
32
+ <a href="https://external.com/link">External Link</a>
33
+ </body>
34
+ </html>
35
+ `;
36
+ }
37
+ }
38
+ async evaluate(fn) {
39
+ if (this._content) {
40
+ return fn.toString().includes('outerHTML') ? this._content : fn();
41
+ }
42
+ return fn();
43
+ }
44
+ }
45
+
46
+ class MockBrowser {
47
+ constructor() {
48
+ this._pages = [];
49
+ }
50
+
51
+ async newPage() {
52
+ const page = new MockPage('about:blank');
53
+ this._pages.push(page);
54
+ return page;
55
+ }
56
+
57
+ async pages() {
58
+ return this._pages;
59
+ }
60
+ }
61
+
62
+ // Test framework
63
+ let testsPassed = 0;
64
+ let testsFailed = 0;
65
+
66
+ function assert(condition, message) {
67
+ if (!condition) {
68
+ console.error(`❌ FAILED: ${message}`);
69
+ testsFailed++;
70
+ throw new Error(message);
71
+ } else {
72
+ console.log(`✅ PASSED: ${message}`);
73
+ testsPassed++;
74
+ }
75
+ }
76
+
77
+ async function test(name, fn) {
78
+ console.log(`\n🧪 Test: ${name}`);
79
+ try {
80
+ await fn();
81
+ } catch (error) {
82
+ console.error(` Error: ${error.message}`);
83
+ }
84
+ }
85
+
86
+ // Tests
87
+ async function runTests() {
88
+ console.log('🚀 Starting Domain Tab Pooling Tests\n');
89
+
90
+ await test('Should create new tab for first domain', async () => {
91
+ const domainPages = new Map();
92
+ const browser = new MockBrowser();
93
+ const url = 'https://github.com/user/repo';
94
+ const hostname = new URL(url).hostname;
95
+
96
+ // No existing page for this domain
97
+ assert(!domainPages.has(hostname), 'Domain should not exist in map initially');
98
+
99
+ // Create new page
100
+ const page = await browser.newPage();
101
+ domainPages.set(hostname, page);
102
+
103
+ assert(domainPages.has(hostname), 'Domain should be added to map');
104
+ assert(domainPages.get(hostname) === page, 'Correct page should be stored');
105
+ });
106
+
107
+ await test('Should reuse tab for same domain', async () => {
108
+ const domainPages = new Map();
109
+ const browser = new MockBrowser();
110
+ const hostname = 'github.com';
111
+
112
+ // Create first page for domain
113
+ const page1 = await browser.newPage();
114
+ await page1.goto('https://github.com/repo1');
115
+ domainPages.set(hostname, page1);
116
+
117
+ // Try to fetch another URL from same domain
118
+ const existingPage = domainPages.get(hostname);
119
+ assert(existingPage === page1, 'Should return same page for same domain');
120
+ assert(!existingPage.isClosed(), 'Page should still be open');
121
+ });
122
+
123
+ await test('Should create new tab for different domain', async () => {
124
+ const domainPages = new Map();
125
+ const browser = new MockBrowser();
126
+
127
+ // First domain
128
+ const page1 = await browser.newPage();
129
+ await page1.goto('https://github.com/repo');
130
+ domainPages.set('github.com', page1);
131
+
132
+ // Second domain - should create new tab
133
+ const hostname2 = 'microsoft.com';
134
+ assert(!domainPages.has(hostname2), 'Second domain should not exist yet');
135
+
136
+ const page2 = await browser.newPage();
137
+ await page2.goto('https://microsoft.com/docs');
138
+ domainPages.set(hostname2, page2);
139
+
140
+ assert(domainPages.has('github.com'), 'First domain should still exist');
141
+ assert(domainPages.has('microsoft.com'), 'Second domain should now exist');
142
+ assert(page1 !== page2, 'Should be different page objects');
143
+ assert(!page1.isClosed(), 'First page should still be open');
144
+ });
145
+
146
+ await test('Should reuse tab when returning to previous domain', async () => {
147
+ const domainPages = new Map();
148
+ const browser = new MockBrowser();
149
+
150
+ // Domain 1
151
+ const page1 = await browser.newPage();
152
+ domainPages.set('github.com', page1);
153
+
154
+ // Domain 2
155
+ const page2 = await browser.newPage();
156
+ domainPages.set('microsoft.com', page2);
157
+
158
+ // Back to domain 1
159
+ const reusedPage = domainPages.get('github.com');
160
+ assert(reusedPage === page1, 'Should reuse original page for domain 1');
161
+ assert(!reusedPage.isClosed(), 'Reused page should still be open');
162
+ assert(domainPages.size === 2, 'Should have 2 domains in map');
163
+ });
164
+
165
+ await test('Should handle closed tabs gracefully', async () => {
166
+ const domainPages = new Map();
167
+ const browser = new MockBrowser();
168
+ const hostname = 'github.com';
169
+
170
+ // Create and store page
171
+ const page = await browser.newPage();
172
+ domainPages.set(hostname, page);
173
+
174
+ // Simulate user closing the tab
175
+ page.close();
176
+
177
+ // Check if page is closed
178
+ const existingPage = domainPages.get(hostname);
179
+ if (existingPage && existingPage.isClosed()) {
180
+ domainPages.delete(hostname);
181
+ }
182
+
183
+ assert(!domainPages.has(hostname), 'Closed page should be removed from map');
184
+ });
185
+
186
+ await test('Should extract hostname correctly from URLs', async () => {
187
+ const testCases = [
188
+ { url: 'https://github.com/user/repo', expected: 'github.com' },
189
+ { url: 'https://microsoft.com/docs/page', expected: 'microsoft.com' },
190
+ { url: 'https://subdomain.example.com/path', expected: 'subdomain.example.com' },
191
+ { url: 'http://localhost:3000/test', expected: 'localhost' },
192
+ ];
193
+
194
+ for (const { url, expected } of testCases) {
195
+ const hostname = new URL(url).hostname;
196
+ assert(hostname === expected, `Hostname for ${url} should be ${expected}, got ${hostname}`);
197
+ }
198
+ });
199
+
200
+ await test('Should handle invalid URLs', async () => {
201
+ let errorThrown = false;
202
+ try {
203
+ new URL('not-a-valid-url');
204
+ } catch (error) {
205
+ errorThrown = true;
206
+ }
207
+ assert(errorThrown, 'Invalid URL should throw error');
208
+ });
209
+
210
+ await test('Should clear all pages on browser disconnect', async () => {
211
+ const domainPages = new Map();
212
+ const browser = new MockBrowser();
213
+
214
+ // Add multiple domains
215
+ const page1 = await browser.newPage();
216
+ domainPages.set('github.com', page1);
217
+
218
+ const page2 = await browser.newPage();
219
+ domainPages.set('microsoft.com', page2);
220
+
221
+ const page3 = await browser.newPage();
222
+ domainPages.set('google.com', page3);
223
+
224
+ assert(domainPages.size === 3, 'Should have 3 domains before disconnect');
225
+
226
+ // Simulate browser disconnect
227
+ domainPages.clear();
228
+
229
+ assert(domainPages.size === 0, 'All domains should be cleared after disconnect');
230
+ });
231
+
232
+ await test('Should handle multiple requests to same domain', async () => {
233
+ const domainPages = new Map();
234
+ const browser = new MockBrowser();
235
+ const hostname = 'github.com';
236
+
237
+ // First request
238
+ const page = await browser.newPage();
239
+ await page.goto('https://github.com/repo1');
240
+ domainPages.set(hostname, page);
241
+
242
+ // Multiple subsequent requests to same domain
243
+ for (let i = 2; i <= 5; i++) {
244
+ const existingPage = domainPages.get(hostname);
245
+ assert(existingPage === page, `Request ${i} should reuse same page`);
246
+ await existingPage.goto(`https://github.com/repo${i}`);
247
+ }
248
+
249
+ assert(domainPages.size === 1, 'Should still have only 1 domain in map');
250
+ });
251
+
252
+ await test('Should open internal eng.ms page', async () => {
253
+ const domainPages = new Map();
254
+ const browser = new MockBrowser();
255
+ const url = 'https://eng.ms/docs/products/geneva';
256
+ const hostname = new URL(url).hostname;
257
+
258
+ // First request to eng.ms domain
259
+ assert(!domainPages.has(hostname), 'eng.ms domain should not exist initially');
260
+
261
+ const page = await browser.newPage();
262
+ await page.goto(url);
263
+ domainPages.set(hostname, page);
264
+
265
+ assert(domainPages.has(hostname), 'eng.ms domain should be added to map');
266
+ assert(page.url() === url, 'Page URL should match requested URL');
267
+ assert(!page.isClosed(), 'Page should remain open');
268
+ });
269
+
270
+ await test('Should extract and load 5 URLs from same domain', async () => {
271
+ const domainPages = new Map();
272
+ const browser = new MockBrowser();
273
+ const initialUrl = 'https://eng.ms/docs/products/geneva';
274
+ const hostname = new URL(initialUrl).hostname;
275
+
276
+ // First: Load the initial page
277
+ const page = await browser.newPage();
278
+ await page.goto(initialUrl);
279
+ domainPages.set(hostname, page);
280
+
281
+ // Extract HTML content
282
+ const html = await page.evaluate(() => document.documentElement.outerHTML);
283
+ assert(html.includes('Geneva Documentation'), 'Page should contain Geneva content');
284
+
285
+ // Extract URLs from the same domain
286
+ const urlPattern = /href="(https:\/\/eng\.ms\/[^"]+)"/g;
287
+ const extractedUrls = [];
288
+ let match;
289
+ while ((match = urlPattern.exec(html)) !== null && extractedUrls.length < 5) {
290
+ extractedUrls.push(match[1]);
291
+ }
292
+
293
+ assert(extractedUrls.length === 5, `Should extract 5 URLs, got ${extractedUrls.length}`);
294
+
295
+ // Verify all URLs are from eng.ms domain
296
+ for (const url of extractedUrls) {
297
+ const urlHostname = new URL(url).hostname;
298
+ assert(urlHostname === hostname, `All URLs should be from ${hostname}, got ${urlHostname}`);
299
+ }
300
+
301
+ // Load each of the 5 URLs and verify tab reuse
302
+ const reusedPage = domainPages.get(hostname);
303
+ assert(reusedPage === page, 'Should reuse same page for same domain');
304
+
305
+ for (let i = 0; i < extractedUrls.length; i++) {
306
+ await reusedPage.goto(extractedUrls[i]);
307
+ assert(reusedPage.url() === extractedUrls[i], `URL ${i+1} should be loaded: ${extractedUrls[i]}`);
308
+ assert(!reusedPage.isClosed(), `Page should remain open after loading URL ${i+1}`);
309
+ }
310
+
311
+ assert(domainPages.size === 1, 'Should still have only 1 domain (eng.ms) in map after all loads');
312
+ });
313
+
314
+ // Summary
315
+ console.log('\n' + '='.repeat(50));
316
+ console.log(`✅ Tests Passed: ${testsPassed}`);
317
+ console.log(`❌ Tests Failed: ${testsFailed}`);
318
+ console.log('='.repeat(50));
319
+
320
+ if (testsFailed > 0) {
321
+ process.exit(1);
322
+ }
323
+ }
324
+
325
+ // Run tests
326
+ runTests().catch(error => {
327
+ console.error('Test suite failed:', error);
328
+ process.exit(1);
329
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Integration tests - REQUIRES REAL CHROME AND USER AUTHENTICATION
3
+ * These tests will actually open Chrome browser and require manual login
4
+ * Run with: node tests/integration.test.js
5
+ */
6
+
7
+ import { fileURLToPath } from 'url';
8
+ import path from 'path';
9
+ import { fetchPage } from '../src/mcp-browser.js';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+
14
+ // Test framework
15
+ let testsPassed = 0;
16
+ let testsFailed = 0;
17
+
18
+ function assert(condition, message) {
19
+ if (!condition) {
20
+ console.error(`❌ FAILED: ${message}`);
21
+ testsFailed++;
22
+ throw new Error(message);
23
+ } else {
24
+ console.log(`✅ PASSED: ${message}`);
25
+ testsPassed++;
26
+ }
27
+ }
28
+
29
+ async function test(name, fn) {
30
+ console.log(`\n🧪 Test: ${name}`);
31
+ try {
32
+ await fn();
33
+ } catch (error) {
34
+ console.error(` Error: ${error.message}`);
35
+ }
36
+ }
37
+
38
+ // Integration Tests
39
+ async function runIntegrationTests() {
40
+ console.log('🚀 Starting Integration Tests (REAL CHROME)\n');
41
+ console.log('⚠️ This will open Chrome browser and may require authentication');
42
+ console.log('⚠️ fetchPage function will WAIT for you to complete authentication\n');
43
+
44
+ try {
45
+ await test('Should fetch eng.ms page, extract links, and load them (full Copilot workflow)', async () => {
46
+ const url = 'https://eng.ms/docs/products/geneva';
47
+
48
+ // Step 1: Fetch initial page (with auth waiting)
49
+ console.log(` 📄 Step 1: Fetching ${url}`);
50
+ console.log(` ⏳ Function will wait up to 10 minutes for authentication...`);
51
+ console.log(` 💡 Complete login in the browser that opens`);
52
+
53
+ const result = await fetchPage({ url });
54
+
55
+ console.log(` ✅ Result: ${result.success ? 'SUCCESS' : 'FAILED'}`);
56
+ if (result.success) {
57
+ console.log(` 🔗 Final URL: ${result.url}`);
58
+ console.log(` 📄 HTML length: ${result.html?.length || 0} chars`);
59
+ } else {
60
+ console.log(` ❌ Error: ${result.error}`);
61
+ console.log(` 💡 Hint: ${result.hint}`);
62
+ }
63
+
64
+ assert(result.success, 'Should successfully fetch page after authentication');
65
+ assert(result.url.includes('eng.ms'), `URL should be from eng.ms domain, got: ${result.url}`);
66
+ assert(result.html && result.html.length > 0, 'Should return HTML content');
67
+
68
+ // Step 2: Extract ALL links from HTML, then pick 5 randomly
69
+ console.log(`\n 📋 Step 2: Extracting all links from HTML...`);
70
+
71
+ const baseUrl = new URL(result.url);
72
+ const urlPattern = /href=["']([^"']+)["']/g;
73
+ const allUrls = [];
74
+ let match;
75
+
76
+ // Static asset extensions to skip
77
+ const skipExtensions = ['.css', '.js', '.ico', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.woff', '.woff2', '.ttf', '.eot'];
78
+
79
+ // Extract ALL URLs first
80
+ while ((match = urlPattern.exec(result.html)) !== null) {
81
+ let foundUrl = match[1];
82
+
83
+ // Skip anchor links
84
+ if (foundUrl.includes('#')) continue;
85
+
86
+ // Convert relative URLs to absolute
87
+ if (foundUrl.startsWith('/')) {
88
+ foundUrl = `${baseUrl.origin}${foundUrl}`;
89
+ } else if (!foundUrl.startsWith('http')) {
90
+ continue; // Skip other relative URLs
91
+ }
92
+
93
+ // Skip static assets (check path without query string)
94
+ const urlWithoutQuery = foundUrl.split('?')[0];
95
+ if (skipExtensions.some(ext => urlWithoutQuery.toLowerCase().endsWith(ext))) continue;
96
+
97
+ // Only include eng.ms URLs (pages)
98
+ if (foundUrl.includes('eng.ms')) {
99
+ allUrls.push(foundUrl);
100
+ }
101
+ }
102
+
103
+ console.log(` 📊 Total page URLs found: ${allUrls.length}`);
104
+
105
+ // Remove duplicates
106
+ const uniqueUrls = [...new Set(allUrls)];
107
+ console.log(` 🔗 Unique page URLs: ${uniqueUrls.length}`);
108
+
109
+ // Randomly pick 5 URLs
110
+ const shuffled = uniqueUrls.sort(() => Math.random() - 0.5);
111
+ const extractedUrls = shuffled.slice(0, 5);
112
+
113
+ console.log(` 🎲 Randomly selected ${extractedUrls.length} URLs to test:`);
114
+ extractedUrls.forEach((link, i) => console.log(` ${i+1}. ${link}`));
115
+
116
+ assert(extractedUrls.length > 0, `Should extract at least one eng.ms URL, found ${extractedUrls.length}`);
117
+
118
+ // Step 3: Load each extracted URL (tab reuse)
119
+ console.log(`\n 🔄 Step 3: Loading extracted links (using same tab)...`);
120
+
121
+ const linksToTest = extractedUrls.slice(0, Math.min(5, extractedUrls.length));
122
+ for (let i = 0; i < linksToTest.length; i++) {
123
+ const link = linksToTest[i];
124
+ console.log(` 📄 Loading link ${i+1}/${linksToTest.length}: ${link}`);
125
+
126
+ const linkResult = await fetchPage({ url: link });
127
+
128
+ console.log(` ✅ Loaded: ${linkResult.url}`);
129
+ assert(linkResult.success, `Should successfully load link ${i+1}: ${link}`);
130
+ assert(linkResult.html && linkResult.html.length > 0, `Link ${i+1} should return HTML content`);
131
+ }
132
+ });
133
+
134
+ } catch (error) {
135
+ console.error('\n❌ Test suite error:', error.message);
136
+ testsFailed++;
137
+ } finally {
138
+ // Summary
139
+ console.log('\n' + '='.repeat(50));
140
+ console.log(`✅ Tests Passed: ${testsPassed}`);
141
+ console.log(`❌ Tests Failed: ${testsFailed}`);
142
+ console.log('='.repeat(50));
143
+ console.log('\n💡 Browser left open for manual inspection');
144
+
145
+ if (testsFailed > 0) {
146
+ process.exit(1);
147
+ }
148
+
149
+ // Exit immediately without waiting for browser
150
+ process.exit(0);
151
+ }
152
+ }
153
+
154
+ // Run tests
155
+ runIntegrationTests().catch(error => {
156
+ console.error('Test suite failed:', error);
157
+ process.exit(1);
158
+ });
@@ -0,0 +1,154 @@
1
+ import { describe, it } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { spawn } from 'child_process';
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname, join } from 'path';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+
10
+ describe('MCP Server', () => {
11
+ it('should start and respond to initialize request', async () => {
12
+ const mcpProcess = spawn('node', [join(__dirname, '..', 'src', 'mcp-browser.js')], {
13
+ stdio: ['pipe', 'pipe', 'inherit']
14
+ });
15
+
16
+ try {
17
+ const initRequest = {
18
+ jsonrpc: '2.0',
19
+ id: 1,
20
+ method: 'initialize',
21
+ params: {
22
+ protocolVersion: '2024-11-05',
23
+ capabilities: {},
24
+ clientInfo: { name: 'test', version: '1.0' }
25
+ }
26
+ };
27
+
28
+ const response = await new Promise((resolve, reject) => {
29
+ const timeout = setTimeout(() => {
30
+ reject(new Error('Server did not respond to initialize request within 5 seconds'));
31
+ }, 5000);
32
+
33
+ let buffer = '';
34
+ mcpProcess.stdout.on('data', (data) => {
35
+ buffer += data.toString();
36
+ const lines = buffer.split('\n');
37
+
38
+ for (const line of lines) {
39
+ if (line.trim()) {
40
+ try {
41
+ const parsed = JSON.parse(line);
42
+ if (parsed.id === 1) {
43
+ clearTimeout(timeout);
44
+ resolve(parsed);
45
+ return;
46
+ }
47
+ } catch (e) {
48
+ // Not JSON, continue
49
+ }
50
+ }
51
+ }
52
+ });
53
+
54
+ mcpProcess.on('error', (err) => {
55
+ clearTimeout(timeout);
56
+ reject(err);
57
+ });
58
+
59
+ mcpProcess.on('exit', (code) => {
60
+ clearTimeout(timeout);
61
+ reject(new Error(`Server exited with code ${code} before responding`));
62
+ });
63
+
64
+ mcpProcess.stdin.write(JSON.stringify(initRequest) + '\n');
65
+ });
66
+
67
+ assert.ok(response.result, 'Initialize response should have result');
68
+ assert.equal(response.result.protocolVersion, '2024-11-05', 'Protocol version should match');
69
+ assert.ok(response.result.serverInfo, 'Server info should be present');
70
+ assert.equal(response.result.serverInfo.name, 'MCPBrowser', 'Server name should be MCPBrowser');
71
+ } finally {
72
+ mcpProcess.kill();
73
+ }
74
+ });
75
+
76
+ it('should respond to tools/list request', async () => {
77
+ const mcpProcess = spawn('node', [join(__dirname, '..', 'src', 'mcp-browser.js')], {
78
+ stdio: ['pipe', 'pipe', 'inherit']
79
+ });
80
+
81
+ try {
82
+ // First initialize
83
+ const initRequest = {
84
+ jsonrpc: '2.0',
85
+ id: 1,
86
+ method: 'initialize',
87
+ params: {
88
+ protocolVersion: '2024-11-05',
89
+ capabilities: {},
90
+ clientInfo: { name: 'test', version: '1.0' }
91
+ }
92
+ };
93
+
94
+ mcpProcess.stdin.write(JSON.stringify(initRequest) + '\n');
95
+
96
+ // Wait a bit for initialization
97
+ await new Promise(resolve => setTimeout(resolve, 500));
98
+
99
+ // Then request tools list
100
+ const toolsRequest = {
101
+ jsonrpc: '2.0',
102
+ id: 2,
103
+ method: 'tools/list',
104
+ params: {}
105
+ };
106
+
107
+ const response = await new Promise((resolve, reject) => {
108
+ const timeout = setTimeout(() => {
109
+ reject(new Error('Server did not respond to tools/list request within 5 seconds'));
110
+ }, 5000);
111
+
112
+ let buffer = '';
113
+ mcpProcess.stdout.on('data', (data) => {
114
+ buffer += data.toString();
115
+ const lines = buffer.split('\n');
116
+
117
+ for (const line of lines) {
118
+ if (line.trim()) {
119
+ try {
120
+ const parsed = JSON.parse(line);
121
+ if (parsed.id === 2) {
122
+ clearTimeout(timeout);
123
+ resolve(parsed);
124
+ return;
125
+ }
126
+ } catch (e) {
127
+ // Not JSON, continue
128
+ }
129
+ }
130
+ }
131
+ });
132
+
133
+ mcpProcess.on('error', (err) => {
134
+ clearTimeout(timeout);
135
+ reject(err);
136
+ });
137
+
138
+ mcpProcess.stdin.write(JSON.stringify(toolsRequest) + '\n');
139
+ });
140
+
141
+ assert.ok(response.result, 'Tools/list response should have result');
142
+ assert.ok(response.result.tools, 'Result should have tools array');
143
+ assert.ok(Array.isArray(response.result.tools), 'Tools should be an array');
144
+ assert.ok(response.result.tools.length > 0, 'Should have at least one tool');
145
+
146
+ const fetchTool = response.result.tools.find(t => t.name === 'fetch_webpage_protected');
147
+ assert.ok(fetchTool, 'Should have fetch_webpage_protected tool');
148
+ assert.ok(fetchTool.description, 'Tool should have description');
149
+ assert.ok(fetchTool.inputSchema, 'Tool should have input schema');
150
+ } finally {
151
+ mcpProcess.kill();
152
+ }
153
+ });
154
+ });