qwksearch-mcp-server 1.0.28 → 1.0.30
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.
- package/package.json +9 -3
- package/test/extract-page.test.ts +120 -0
- package/test/helpers/fake-server.ts +37 -0
- package/test/render-page.test.ts +145 -0
- package/test/search-instance.test.ts +26 -0
- package/test/web-search.test.ts +114 -0
- package/vitest.config.ts +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qwksearch-mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.30",
|
|
4
4
|
"description": "MCP server exposing web search and content extraction tools via stdio transport",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,12 +18,18 @@
|
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
20
|
"start": "bun run bin/qwksearch-mcp.ts",
|
|
21
|
-
"dev": "bun --watch run bin/qwksearch-mcp.ts"
|
|
21
|
+
"dev": "bun --watch run bin/qwksearch-mcp.ts",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:coverage": "vitest run --coverage"
|
|
22
24
|
},
|
|
23
25
|
"dependencies": {
|
|
24
26
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
25
27
|
"search-web-api": "^1.0.76",
|
|
26
|
-
"extract-webpage": "^1.2.
|
|
28
|
+
"extract-webpage": "^1.2.170",
|
|
27
29
|
"zod": "^4.3.6"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
33
|
+
"vitest": "^4.0.18"
|
|
28
34
|
}
|
|
29
35
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createFakeServer } from './helpers/fake-server';
|
|
3
|
+
|
|
4
|
+
const extractContent = vi.fn();
|
|
5
|
+
vi.mock('extract-webpage/url-to-content/url-to-content', () => ({ extractContent }));
|
|
6
|
+
|
|
7
|
+
const { registerExtractPageTool } = await import('../src/tools/extract-page.js');
|
|
8
|
+
|
|
9
|
+
function register() {
|
|
10
|
+
const fake = createFakeServer();
|
|
11
|
+
registerExtractPageTool(fake.server);
|
|
12
|
+
return fake.get('extract_page');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULT_ARGS = { url: 'https://example.com/article', images: true, links: true, formatting: true, timeout: 10 };
|
|
16
|
+
|
|
17
|
+
describe('extract_page tool registration', () => {
|
|
18
|
+
it('registers under the expected name', () => {
|
|
19
|
+
expect(register().name).toBe('extract_page');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('declares the extraction options as inputs', () => {
|
|
23
|
+
const schema = register().config.inputSchema;
|
|
24
|
+
|
|
25
|
+
expect(Object.keys(schema).sort()).toEqual(['formatting', 'images', 'links', 'timeout', 'url']);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('extract_page handler', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
extractContent.mockReset();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('forwards the options and always requests absolute URLs', async () => {
|
|
35
|
+
extractContent.mockResolvedValue({ title: 'Article' });
|
|
36
|
+
|
|
37
|
+
await register().handler({ ...DEFAULT_ARGS, images: false, timeout: 30 });
|
|
38
|
+
|
|
39
|
+
expect(extractContent).toHaveBeenCalledWith('https://example.com/article', {
|
|
40
|
+
images: false,
|
|
41
|
+
links: true,
|
|
42
|
+
formatting: true,
|
|
43
|
+
absoluteURLs: true,
|
|
44
|
+
timeout: 30,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('renders the full set of metadata fields', async () => {
|
|
49
|
+
extractContent.mockResolvedValue({
|
|
50
|
+
url: 'https://example.com/canonical',
|
|
51
|
+
title: 'Article',
|
|
52
|
+
author: 'A. Writer',
|
|
53
|
+
date: '2024-01-01',
|
|
54
|
+
source: 'example.com',
|
|
55
|
+
word_count: 812,
|
|
56
|
+
cite: 'Writer, A. (2024)',
|
|
57
|
+
html: '<p>Body</p>',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const text = (await register().handler(DEFAULT_ARGS)).content[0].text;
|
|
61
|
+
|
|
62
|
+
expect(text).toContain('Content extracted from: https://example.com/canonical');
|
|
63
|
+
expect(text).toContain('Title: Article');
|
|
64
|
+
expect(text).toContain('Author: A. Writer');
|
|
65
|
+
expect(text).toContain('Date: 2024-01-01');
|
|
66
|
+
expect(text).toContain('Source: example.com');
|
|
67
|
+
expect(text).toContain('Word Count: 812');
|
|
68
|
+
expect(text).toContain('Citation: Writer, A. (2024)');
|
|
69
|
+
expect(text).toContain('<p>Body</p>');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('falls back to the requested URL when the result has none', async () => {
|
|
73
|
+
extractContent.mockResolvedValue({ title: 'Article' });
|
|
74
|
+
|
|
75
|
+
const text = (await register().handler(DEFAULT_ARGS)).content[0].text;
|
|
76
|
+
|
|
77
|
+
expect(text).toContain('Content extracted from: https://example.com/article');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('omits metadata lines for fields the extractor did not return', async () => {
|
|
81
|
+
extractContent.mockResolvedValue({ title: 'Article' });
|
|
82
|
+
|
|
83
|
+
const text = (await register().handler(DEFAULT_ARGS)).content[0].text;
|
|
84
|
+
|
|
85
|
+
expect(text).not.toContain('Author:');
|
|
86
|
+
expect(text).not.toContain('Citation:');
|
|
87
|
+
expect(text).not.toContain('Word Count:');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('flags an extractor-reported error', async () => {
|
|
91
|
+
extractContent.mockResolvedValue({ error: 'paywalled' });
|
|
92
|
+
|
|
93
|
+
const result = await register().handler(DEFAULT_ARGS);
|
|
94
|
+
|
|
95
|
+
expect(result.isError).toBe(true);
|
|
96
|
+
expect(result.content[0].text).toBe(
|
|
97
|
+
'Could not extract content from "https://example.com/article".'
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('flags an empty extractor result', async () => {
|
|
102
|
+
extractContent.mockResolvedValue(null);
|
|
103
|
+
|
|
104
|
+
const result = await register().handler(DEFAULT_ARGS);
|
|
105
|
+
|
|
106
|
+
expect(result.isError).toBe(true);
|
|
107
|
+
expect(result.content[0].text).toContain('Could not extract content');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('reports a thrown error with its message', async () => {
|
|
111
|
+
extractContent.mockRejectedValue(new Error('DNS failure'));
|
|
112
|
+
|
|
113
|
+
const result = await register().handler(DEFAULT_ARGS);
|
|
114
|
+
|
|
115
|
+
expect(result.isError).toBe(true);
|
|
116
|
+
expect(result.content[0].text).toBe(
|
|
117
|
+
'Extraction failed for "https://example.com/article": DNS failure'
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
|
|
3
|
+
export type RegisteredTool = {
|
|
4
|
+
name: string;
|
|
5
|
+
config: {
|
|
6
|
+
description: string;
|
|
7
|
+
inputSchema: Record<string, unknown>;
|
|
8
|
+
};
|
|
9
|
+
handler: (args: Record<string, unknown>) => Promise<{
|
|
10
|
+
content: { type: 'text'; text: string }[];
|
|
11
|
+
isError?: boolean;
|
|
12
|
+
}>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Captures `registerTool` calls so a tool's handler can be invoked directly,
|
|
17
|
+
* without standing up an MCP transport.
|
|
18
|
+
*/
|
|
19
|
+
export function createFakeServer() {
|
|
20
|
+
const tools = new Map<string, RegisteredTool>();
|
|
21
|
+
|
|
22
|
+
const server = {
|
|
23
|
+
registerTool(name: string, config: RegisteredTool['config'], handler: RegisteredTool['handler']) {
|
|
24
|
+
tools.set(name, { name, config, handler });
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
server: server as unknown as McpServer,
|
|
30
|
+
tools,
|
|
31
|
+
get(name: string): RegisteredTool {
|
|
32
|
+
const tool = tools.get(name);
|
|
33
|
+
if (!tool) throw new Error(`Tool "${name}" was never registered`);
|
|
34
|
+
return tool;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { registerRenderPageTool } from '../src/tools/render-page.js';
|
|
3
|
+
import { createFakeServer } from './helpers/fake-server';
|
|
4
|
+
|
|
5
|
+
function register() {
|
|
6
|
+
const fake = createFakeServer();
|
|
7
|
+
registerRenderPageTool(fake.server);
|
|
8
|
+
return fake.get('render_page_with_javascript');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DEFAULT_ARGS = {
|
|
12
|
+
url: 'https://example.com/app',
|
|
13
|
+
wait: 0,
|
|
14
|
+
timeout: 30000,
|
|
15
|
+
waitUntil: 'networkidle2',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function mockFetch(body: unknown, init: { ok?: boolean; text?: string } = {}) {
|
|
19
|
+
const fetchMock = vi.fn(async () => ({
|
|
20
|
+
ok: init.ok ?? true,
|
|
21
|
+
json: async () => body,
|
|
22
|
+
text: async () => init.text ?? '',
|
|
23
|
+
}));
|
|
24
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
25
|
+
return fetchMock;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('render_page_with_javascript tool registration', () => {
|
|
29
|
+
it('registers under the expected name', () => {
|
|
30
|
+
expect(register().name).toBe('render_page_with_javascript');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('declares the render options as inputs', () => {
|
|
34
|
+
const schema = register().config.inputSchema;
|
|
35
|
+
|
|
36
|
+
expect(Object.keys(schema).sort()).toEqual(['timeout', 'url', 'wait', 'waitUntil']);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('render_page_with_javascript handler', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.unstubAllEnvs();
|
|
43
|
+
vi.unstubAllGlobals();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('refuses to run without SCRAPER_API_KEY', async () => {
|
|
47
|
+
vi.stubEnv('SCRAPER_API_KEY', '');
|
|
48
|
+
|
|
49
|
+
const result = await register().handler(DEFAULT_ARGS);
|
|
50
|
+
|
|
51
|
+
expect(result.isError).toBe(true);
|
|
52
|
+
expect(result.content[0].text).toContain('SCRAPER_API_KEY environment variable not set');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('posts to the default scraper with a bearer token', async () => {
|
|
56
|
+
vi.stubEnv('SCRAPER_API_KEY', 'secret-key');
|
|
57
|
+
const fetchMock = mockFetch({ html: '<html></html>' });
|
|
58
|
+
|
|
59
|
+
await register().handler(DEFAULT_ARGS);
|
|
60
|
+
|
|
61
|
+
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
62
|
+
expect(url).toBe('https://scraper.qwksearch.workers.dev/api/render');
|
|
63
|
+
expect(init.method).toBe('POST');
|
|
64
|
+
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer secret-key');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('honours a SCRAPER_URL override', async () => {
|
|
68
|
+
vi.stubEnv('SCRAPER_API_KEY', 'secret-key');
|
|
69
|
+
vi.stubEnv('SCRAPER_URL', 'https://scraper.internal.example');
|
|
70
|
+
const fetchMock = mockFetch({ html: '' });
|
|
71
|
+
|
|
72
|
+
await register().handler(DEFAULT_ARGS);
|
|
73
|
+
|
|
74
|
+
expect(fetchMock.mock.calls[0][0]).toBe('https://scraper.internal.example/api/render');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('forwards the render options in the request body', async () => {
|
|
78
|
+
vi.stubEnv('SCRAPER_API_KEY', 'secret-key');
|
|
79
|
+
const fetchMock = mockFetch({ html: '' });
|
|
80
|
+
|
|
81
|
+
await register().handler({ ...DEFAULT_ARGS, wait: 500, timeout: 45000, waitUntil: 'load' });
|
|
82
|
+
|
|
83
|
+
const body = JSON.parse((fetchMock.mock.calls[0][1] as unknown as RequestInit).body as string);
|
|
84
|
+
expect(body).toMatchObject({
|
|
85
|
+
url: 'https://example.com/app',
|
|
86
|
+
wait: 500,
|
|
87
|
+
timeout: 45000,
|
|
88
|
+
waitUntil: 'load',
|
|
89
|
+
blockImages: true,
|
|
90
|
+
bypassCaptcha: true,
|
|
91
|
+
format: 'json',
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('renders the page title, load time and html', async () => {
|
|
96
|
+
vi.stubEnv('SCRAPER_API_KEY', 'secret-key');
|
|
97
|
+
mockFetch({
|
|
98
|
+
url: 'https://example.com/app/final',
|
|
99
|
+
title: 'My App',
|
|
100
|
+
loadTime: 1234,
|
|
101
|
+
html: '<h1>Hi</h1>',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const text = (await register().handler(DEFAULT_ARGS)).content[0].text;
|
|
105
|
+
|
|
106
|
+
expect(text).toContain('Page rendered: https://example.com/app/final');
|
|
107
|
+
expect(text).toContain('Title: My App');
|
|
108
|
+
expect(text).toContain('Load Time: 1234ms');
|
|
109
|
+
expect(text).toContain('<h1>Hi</h1>');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('falls back to the requested URL and tolerates missing html', async () => {
|
|
113
|
+
vi.stubEnv('SCRAPER_API_KEY', 'secret-key');
|
|
114
|
+
mockFetch({});
|
|
115
|
+
|
|
116
|
+
const text = (await register().handler(DEFAULT_ARGS)).content[0].text;
|
|
117
|
+
|
|
118
|
+
expect(text).toContain('Page rendered: https://example.com/app');
|
|
119
|
+
expect(text).not.toContain('Title:');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('surfaces the scraper error body on a non-ok response', async () => {
|
|
123
|
+
vi.stubEnv('SCRAPER_API_KEY', 'secret-key');
|
|
124
|
+
mockFetch(null, { ok: false, text: 'quota exceeded' });
|
|
125
|
+
|
|
126
|
+
const result = await register().handler(DEFAULT_ARGS);
|
|
127
|
+
|
|
128
|
+
expect(result.isError).toBe(true);
|
|
129
|
+
expect(result.content[0].text).toBe('Render failed: quota exceeded');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('reports a thrown error with the requested URL', async () => {
|
|
133
|
+
vi.stubEnv('SCRAPER_API_KEY', 'secret-key');
|
|
134
|
+
vi.stubGlobal('fetch', vi.fn(async () => {
|
|
135
|
+
throw new Error('socket hang up');
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
const result = await register().handler(DEFAULT_ARGS);
|
|
139
|
+
|
|
140
|
+
expect(result.isError).toBe(true);
|
|
141
|
+
expect(result.content[0].text).toBe(
|
|
142
|
+
'Render failed for "https://example.com/app": socket hang up'
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const constructed: unknown[] = [];
|
|
4
|
+
class FakeSearch {
|
|
5
|
+
constructor() {
|
|
6
|
+
constructed.push(this);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
vi.mock('search-web-api', () => ({ Search: FakeSearch }));
|
|
11
|
+
|
|
12
|
+
const { getSearchInstance } = await import('../src/lib/search-instance.js');
|
|
13
|
+
|
|
14
|
+
describe('getSearchInstance', () => {
|
|
15
|
+
it('returns a Search instance', () => {
|
|
16
|
+
expect(getSearchInstance()).toBeInstanceOf(FakeSearch);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('constructs the instance lazily and only once', () => {
|
|
20
|
+
const first = getSearchInstance();
|
|
21
|
+
const second = getSearchInstance();
|
|
22
|
+
|
|
23
|
+
expect(second).toBe(first);
|
|
24
|
+
expect(constructed).toHaveLength(1);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createFakeServer } from './helpers/fake-server';
|
|
3
|
+
|
|
4
|
+
const search = vi.fn();
|
|
5
|
+
vi.mock('../src/lib/search-instance.js', () => ({
|
|
6
|
+
getSearchInstance: () => ({ search }),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
const { registerWebSearchTool } = await import('../src/tools/web-search.js');
|
|
10
|
+
|
|
11
|
+
function register() {
|
|
12
|
+
const fake = createFakeServer();
|
|
13
|
+
registerWebSearchTool(fake.server);
|
|
14
|
+
return fake.get('web_search');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('web_search tool registration', () => {
|
|
18
|
+
it('registers under the expected name with a description', () => {
|
|
19
|
+
const tool = register();
|
|
20
|
+
|
|
21
|
+
expect(tool.name).toBe('web_search');
|
|
22
|
+
expect(tool.config.description).toContain('Search the web');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('declares query, category and page inputs', () => {
|
|
26
|
+
const tool = register();
|
|
27
|
+
|
|
28
|
+
expect(Object.keys(tool.config.inputSchema).sort()).toEqual(['category', 'page', 'query']);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('web_search handler', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
search.mockReset();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('passes the query, page and category through to the search instance', async () => {
|
|
38
|
+
search.mockResolvedValue([{ title: 'A result', url: 'https://example.com' }]);
|
|
39
|
+
|
|
40
|
+
await register().handler({ query: 'espresso', category: 'news', page: 2 });
|
|
41
|
+
|
|
42
|
+
expect(search).toHaveBeenCalledWith('espresso', 2, undefined, ['news']);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('formats results as a numbered list with URLs', async () => {
|
|
46
|
+
search.mockResolvedValue([
|
|
47
|
+
{ title: 'First', url: 'https://example.com/1', content: 'Snippet one' },
|
|
48
|
+
{ title: 'Second', url: 'https://example.com/2' },
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const result = await register().handler({ query: 'espresso', category: 'general', page: 1 });
|
|
52
|
+
const text = result.content[0].text;
|
|
53
|
+
|
|
54
|
+
expect(text).toContain('Search results for "espresso" (general):');
|
|
55
|
+
expect(text).toContain('1. First');
|
|
56
|
+
expect(text).toContain('URL: https://example.com/1');
|
|
57
|
+
expect(text).toContain('Snippet one');
|
|
58
|
+
expect(text).toContain('2. Second');
|
|
59
|
+
expect(text).toContain('Found 2 results.');
|
|
60
|
+
expect(result.isError).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('falls back to the `link` field when `url` is absent', async () => {
|
|
64
|
+
search.mockResolvedValue([{ title: 'Legacy', link: 'https://example.com/legacy' }]);
|
|
65
|
+
|
|
66
|
+
const result = await register().handler({ query: 'x', category: 'general', page: 1 });
|
|
67
|
+
|
|
68
|
+
expect(result.content[0].text).toContain('URL: https://example.com/legacy');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('lists the contributing engines when present', async () => {
|
|
72
|
+
search.mockResolvedValue([
|
|
73
|
+
{ title: 'A', url: 'https://example.com', engines: ['google', 'brave'] },
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
const result = await register().handler({ query: 'x', category: 'general', page: 1 });
|
|
77
|
+
|
|
78
|
+
expect(result.content[0].text).toContain('Sources: google, brave');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('omits the sources line when the engine list is empty', async () => {
|
|
82
|
+
search.mockResolvedValue([{ title: 'A', url: 'https://example.com', engines: [] }]);
|
|
83
|
+
|
|
84
|
+
const result = await register().handler({ query: 'x', category: 'general', page: 1 });
|
|
85
|
+
|
|
86
|
+
expect(result.content[0].text).not.toContain('Sources:');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('reports an empty result set without flagging an error', async () => {
|
|
90
|
+
search.mockResolvedValue([]);
|
|
91
|
+
|
|
92
|
+
const result = await register().handler({ query: 'nothing here', category: 'general', page: 1 });
|
|
93
|
+
|
|
94
|
+
expect(result.content[0].text).toBe('No search results found for "nothing here".');
|
|
95
|
+
expect(result.isError).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('treats a null result the same as an empty one', async () => {
|
|
99
|
+
search.mockResolvedValue(null);
|
|
100
|
+
|
|
101
|
+
const result = await register().handler({ query: 'nothing', category: 'general', page: 1 });
|
|
102
|
+
|
|
103
|
+
expect(result.content[0].text).toContain('No search results found');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('returns an error payload when the search throws', async () => {
|
|
107
|
+
search.mockRejectedValue(new Error('upstream timeout'));
|
|
108
|
+
|
|
109
|
+
const result = await register().handler({ query: 'x', category: 'general', page: 1 });
|
|
110
|
+
|
|
111
|
+
expect(result.isError).toBe(true);
|
|
112
|
+
expect(result.content[0].text).toBe('Search failed: upstream timeout');
|
|
113
|
+
});
|
|
114
|
+
});
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: 'node',
|
|
7
|
+
include: ['test/**/*.test.ts'],
|
|
8
|
+
restoreMocks: true,
|
|
9
|
+
unstubEnvs: true,
|
|
10
|
+
unstubGlobals: true,
|
|
11
|
+
coverage: {
|
|
12
|
+
provider: 'v8',
|
|
13
|
+
reporter: ['text', 'json', 'html', 'lcov'],
|
|
14
|
+
include: ['src/**/*.ts'],
|
|
15
|
+
exclude: [
|
|
16
|
+
'node_modules/**',
|
|
17
|
+
'dist/**',
|
|
18
|
+
// src/index.ts opens a real stdio transport on import, so it is
|
|
19
|
+
// covered by the bin smoke path rather than by unit tests.
|
|
20
|
+
'src/index.ts',
|
|
21
|
+
'**/*.test.ts',
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|