@youdotcom-oss/mcp 1.3.5-next.5 → 1.3.6
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/README.md +1 -1
- package/bin/stdio.js +17 -39
- package/package.json +17 -39
- package/src/contents/register-contents-tool.ts +90 -0
- package/src/contents/tests/contents.utils.spec.ts +188 -0
- package/src/express/register-express-tool.ts +67 -0
- package/src/express/tests/express.utils.spec.ts +244 -0
- package/src/get-mcp-server.ts +17 -0
- package/src/http.ts +72 -0
- package/src/search/register-search-tool.ts +87 -0
- package/src/search/tests/search.utils.spec.ts +217 -0
- package/src/shared/generate-error-report-link.ts +37 -0
- package/src/shared/get-logger.ts +10 -0
- package/src/shared/tests/shared.utils.spec.ts +160 -0
- package/src/shared/use-client-version.ts +21 -0
- package/src/stdio.ts +24 -0
- package/src/tests/exports.spec.ts +24 -0
- package/src/tests/http.spec.ts +318 -0
- package/src/tests/tool.spec.ts +496 -0
- package/src/utils.ts +8 -0
- package/AGENTS.md +0 -744
- package/CONTRIBUTING.md +0 -246
- package/docs/API.md +0 -319
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, expect, setDefaultTimeout, test } from 'bun:test';
|
|
2
|
+
import type { ExpressAgentMcpResponse } from '../express.schemas.ts';
|
|
3
|
+
import { callExpressAgent, formatExpressAgentResponse } from '../express.utils.ts';
|
|
4
|
+
|
|
5
|
+
const getUserAgent = () => 'MCP/test (You.com; test-client)';
|
|
6
|
+
|
|
7
|
+
setDefaultTimeout(20_000);
|
|
8
|
+
|
|
9
|
+
describe('callExpressAgent', () => {
|
|
10
|
+
test('returns answer only (WITHOUT web_search tools)', async () => {
|
|
11
|
+
const result = await callExpressAgent({
|
|
12
|
+
agentInput: { input: 'What is machine learning?' },
|
|
13
|
+
getUserAgent,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Verify MCP response structure
|
|
17
|
+
expect(result).toHaveProperty('answer');
|
|
18
|
+
expect(typeof result.answer).toBe('string');
|
|
19
|
+
expect(result.answer.length).toBeGreaterThan(0);
|
|
20
|
+
|
|
21
|
+
// Should NOT have results when web_search is not used
|
|
22
|
+
expect(result.results).toBeUndefined();
|
|
23
|
+
|
|
24
|
+
expect(result.agent).toBe('express');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('returns answer and search results (WITH web_search tools)', async () => {
|
|
28
|
+
const result = await callExpressAgent({
|
|
29
|
+
agentInput: {
|
|
30
|
+
input: 'Latest developments in quantum computing',
|
|
31
|
+
tools: [{ type: 'web_search' }],
|
|
32
|
+
},
|
|
33
|
+
getUserAgent,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Verify MCP response has both answer and results
|
|
37
|
+
expect(result).toHaveProperty('answer');
|
|
38
|
+
expect(typeof result.answer).toBe('string');
|
|
39
|
+
expect(result.answer.length).toBeGreaterThan(0);
|
|
40
|
+
|
|
41
|
+
expect(result).toHaveProperty('results');
|
|
42
|
+
expect(result.results).toHaveProperty('web');
|
|
43
|
+
expect(Array.isArray(result.results?.web)).toBe(true);
|
|
44
|
+
expect(result.results?.web.length).toBeGreaterThan(0);
|
|
45
|
+
|
|
46
|
+
// Verify each search result has required fields
|
|
47
|
+
const firstResult = result.results?.web[0];
|
|
48
|
+
expect(firstResult).toHaveProperty('url');
|
|
49
|
+
expect(firstResult).toHaveProperty('title');
|
|
50
|
+
expect(firstResult).toHaveProperty('snippet');
|
|
51
|
+
expect(typeof firstResult?.url).toBe('string');
|
|
52
|
+
expect(typeof firstResult?.title).toBe('string');
|
|
53
|
+
expect(typeof firstResult?.snippet).toBe('string');
|
|
54
|
+
expect(firstResult?.url.length).toBeGreaterThan(0);
|
|
55
|
+
expect(firstResult?.title.length).toBeGreaterThan(0);
|
|
56
|
+
|
|
57
|
+
expect(result.agent).toBe('express');
|
|
58
|
+
}, 30000);
|
|
59
|
+
|
|
60
|
+
test('works without optional parameters', async () => {
|
|
61
|
+
const result = await callExpressAgent({
|
|
62
|
+
agentInput: { input: 'What is the capital of France?' },
|
|
63
|
+
getUserAgent,
|
|
64
|
+
// No progressToken or sendProgress provided
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Should work normally without progress tracking
|
|
68
|
+
expect(result).toHaveProperty('answer');
|
|
69
|
+
expect(result.answer.length).toBeGreaterThan(0);
|
|
70
|
+
expect(result.agent).toBe('express');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('formatExpressAgentResponse', () => {
|
|
75
|
+
test('formats response with answer only (no search results)', () => {
|
|
76
|
+
const mockResponse: ExpressAgentMcpResponse = {
|
|
77
|
+
answer: 'The capital of France is Paris.',
|
|
78
|
+
agent: 'express',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const result = formatExpressAgentResponse(mockResponse);
|
|
82
|
+
|
|
83
|
+
// Verify content array has 1 item (answer only)
|
|
84
|
+
expect(result).toHaveProperty('content');
|
|
85
|
+
expect(Array.isArray(result.content)).toBe(true);
|
|
86
|
+
expect(result.content.length).toBe(1);
|
|
87
|
+
|
|
88
|
+
// Verify answer content
|
|
89
|
+
expect(result.content[0]).toHaveProperty('type', 'text');
|
|
90
|
+
expect(result.content[0]).toHaveProperty('text');
|
|
91
|
+
expect(result.content[0]?.text).toContain('Express Agent Answer');
|
|
92
|
+
expect(result.content[0]?.text).toContain('The capital of France is Paris.');
|
|
93
|
+
|
|
94
|
+
// Verify structuredContent is minimal (not full response)
|
|
95
|
+
expect(result).toHaveProperty('structuredContent');
|
|
96
|
+
expect(result).toHaveProperty('fullResponse');
|
|
97
|
+
expect(result.structuredContent).toHaveProperty('answer');
|
|
98
|
+
expect(result.structuredContent).toHaveProperty('hasResults');
|
|
99
|
+
expect(result.structuredContent).toHaveProperty('resultCount');
|
|
100
|
+
expect(result.structuredContent).toHaveProperty('agent');
|
|
101
|
+
expect(result.structuredContent.answer).toBe(mockResponse.answer);
|
|
102
|
+
expect(result.structuredContent.hasResults).toBe(false);
|
|
103
|
+
expect(result.structuredContent.resultCount).toBe(0);
|
|
104
|
+
// No results, so results field should be undefined
|
|
105
|
+
expect(result.structuredContent.results).toBeUndefined();
|
|
106
|
+
expect(result.fullResponse).toEqual(mockResponse);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('formats response with answer and search results', () => {
|
|
110
|
+
const mockResponse: ExpressAgentMcpResponse = {
|
|
111
|
+
answer: 'Quantum computing is advancing rapidly with recent breakthroughs in error correction.',
|
|
112
|
+
results: {
|
|
113
|
+
web: [
|
|
114
|
+
{
|
|
115
|
+
url: 'https://example.com/quantum1',
|
|
116
|
+
title: 'Quantum Computing Breakthrough',
|
|
117
|
+
snippet: 'Scientists achieve quantum error correction milestone.',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
url: 'https://example.com/quantum2',
|
|
121
|
+
title: 'Latest in Quantum Research',
|
|
122
|
+
snippet: 'New quantum processor demonstrates superiority.',
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
agent: 'express',
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const result = formatExpressAgentResponse(mockResponse);
|
|
130
|
+
|
|
131
|
+
// Verify content array has 2 items (answer + search results)
|
|
132
|
+
expect(result.content.length).toBe(2);
|
|
133
|
+
|
|
134
|
+
// Verify answer comes FIRST
|
|
135
|
+
expect(result.content[0]?.type).toBe('text');
|
|
136
|
+
expect(result.content[0]?.text).toContain('Express Agent Answer');
|
|
137
|
+
expect(result.content[0]?.text).toContain('Quantum computing is advancing rapidly');
|
|
138
|
+
|
|
139
|
+
// Verify search results come SECOND (without URLs in text)
|
|
140
|
+
expect(result.content[1]?.type).toBe('text');
|
|
141
|
+
expect(result.content[1]?.text).toContain('Search Results');
|
|
142
|
+
expect(result.content[1]?.text).toContain('Quantum Computing Breakthrough');
|
|
143
|
+
expect(result.content[1]?.text).toContain('Latest in Quantum Research');
|
|
144
|
+
// URLs should NOT be in text content
|
|
145
|
+
expect(result.content[1]?.text).not.toContain('https://example.com/quantum1');
|
|
146
|
+
expect(result.content[1]?.text).not.toContain('https://example.com/quantum2');
|
|
147
|
+
|
|
148
|
+
// Verify structuredContent is minimal with counts
|
|
149
|
+
expect(result.structuredContent).toHaveProperty('answer');
|
|
150
|
+
expect(result.structuredContent).toHaveProperty('hasResults');
|
|
151
|
+
expect(result.structuredContent).toHaveProperty('resultCount');
|
|
152
|
+
expect(result.structuredContent.answer).toBe(mockResponse.answer);
|
|
153
|
+
expect(result.structuredContent.hasResults).toBe(true);
|
|
154
|
+
expect(result.structuredContent.resultCount).toBe(2);
|
|
155
|
+
|
|
156
|
+
// URLs should be in structuredContent.results
|
|
157
|
+
expect(result.structuredContent).toHaveProperty('results');
|
|
158
|
+
expect(result.structuredContent.results?.web).toBeDefined();
|
|
159
|
+
expect(result.structuredContent.results?.web?.length).toBe(2);
|
|
160
|
+
expect(result.structuredContent.results?.web?.[0]).toEqual({
|
|
161
|
+
url: 'https://example.com/quantum1',
|
|
162
|
+
title: 'Quantum Computing Breakthrough',
|
|
163
|
+
});
|
|
164
|
+
expect(result.structuredContent.results?.web?.[1]).toEqual({
|
|
165
|
+
url: 'https://example.com/quantum2',
|
|
166
|
+
title: 'Latest in Quantum Research',
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Verify fullResponse has complete data
|
|
170
|
+
expect(result.fullResponse).toEqual(mockResponse);
|
|
171
|
+
expect(result.fullResponse.results?.web).toHaveLength(2);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('structuredContent validation for answer only', () => {
|
|
175
|
+
const mockResponse: ExpressAgentMcpResponse = {
|
|
176
|
+
answer: 'Neural networks are computational models inspired by biological neurons.',
|
|
177
|
+
agent: 'express',
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const result = formatExpressAgentResponse(mockResponse);
|
|
181
|
+
|
|
182
|
+
// Verify structure matches minimal schema
|
|
183
|
+
expect(result.structuredContent).toMatchObject({
|
|
184
|
+
answer: expect.any(String),
|
|
185
|
+
hasResults: false,
|
|
186
|
+
resultCount: 0,
|
|
187
|
+
agent: 'express',
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Verify fullResponse has complete data
|
|
191
|
+
expect(result.fullResponse.results).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('structuredContent validation for answer with results', () => {
|
|
195
|
+
const mockResponse: ExpressAgentMcpResponse = {
|
|
196
|
+
answer: 'Recent AI breakthroughs include advances in language models and computer vision.',
|
|
197
|
+
results: {
|
|
198
|
+
web: [
|
|
199
|
+
{
|
|
200
|
+
url: 'https://example.com/ai-breakthrough',
|
|
201
|
+
title: 'AI Breakthrough 2025',
|
|
202
|
+
snippet: 'Major advances in artificial intelligence.',
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
agent: 'express',
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const result = formatExpressAgentResponse(mockResponse);
|
|
210
|
+
|
|
211
|
+
// Verify structuredContent is minimal with counts
|
|
212
|
+
expect(result.structuredContent).toHaveProperty('answer');
|
|
213
|
+
expect(result.structuredContent).toHaveProperty('hasResults');
|
|
214
|
+
expect(result.structuredContent).toHaveProperty('resultCount');
|
|
215
|
+
expect(result.structuredContent).toHaveProperty('agent');
|
|
216
|
+
expect(result.structuredContent.answer).toBe(
|
|
217
|
+
'Recent AI breakthroughs include advances in language models and computer vision.',
|
|
218
|
+
);
|
|
219
|
+
expect(result.structuredContent.agent).toBe('express');
|
|
220
|
+
expect(result.structuredContent.hasResults).toBe(true);
|
|
221
|
+
expect(result.structuredContent.resultCount).toBe(1);
|
|
222
|
+
|
|
223
|
+
// URLs should be in structuredContent.results
|
|
224
|
+
expect(result.structuredContent).toHaveProperty('results');
|
|
225
|
+
expect(result.structuredContent.results?.web).toBeDefined();
|
|
226
|
+
expect(result.structuredContent.results?.web?.length).toBe(1);
|
|
227
|
+
expect(result.structuredContent.results?.web?.[0]).toEqual({
|
|
228
|
+
url: 'https://example.com/ai-breakthrough',
|
|
229
|
+
title: 'AI Breakthrough 2025',
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Verify fullResponse has complete search results
|
|
233
|
+
expect(result.fullResponse).toEqual(mockResponse);
|
|
234
|
+
expect(result.fullResponse.results).toBeDefined();
|
|
235
|
+
expect(Array.isArray(result.fullResponse.results?.web)).toBe(true);
|
|
236
|
+
expect(result.fullResponse.results?.web.length).toBe(1);
|
|
237
|
+
|
|
238
|
+
// Verify search result fields in fullResponse
|
|
239
|
+
const searchResult = result.fullResponse.results?.web[0];
|
|
240
|
+
expect(searchResult?.url).toBe('https://example.com/ai-breakthrough');
|
|
241
|
+
expect(searchResult?.title).toBe('AI Breakthrough 2025');
|
|
242
|
+
expect(searchResult?.snippet).toBe('Major advances in artificial intelligence.');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import packageJson from '../package.json' with { type: 'json' };
|
|
3
|
+
|
|
4
|
+
export const getMCpServer = () =>
|
|
5
|
+
new McpServer(
|
|
6
|
+
{
|
|
7
|
+
name: 'You.com',
|
|
8
|
+
version: packageJson.version,
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
capabilities: {
|
|
12
|
+
logging: {},
|
|
13
|
+
tools: { listChanged: true },
|
|
14
|
+
},
|
|
15
|
+
instructions: `Use this server to search the web, get AI-powered answers with web context, and extract content from web pages using You.com. The you-contents tool extracts page content and returns it in markdown or HTML format. Use HTML format for layout preservation, interactive content, and visual fidelity; use markdown for text extraction and simpler consumption.`,
|
|
16
|
+
},
|
|
17
|
+
);
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { StreamableHTTPTransport } from '@hono/mcp';
|
|
2
|
+
import { type Context, Hono } from 'hono';
|
|
3
|
+
import { trimTrailingSlash } from 'hono/trailing-slash';
|
|
4
|
+
import packageJson from '../package.json' with { type: 'json' };
|
|
5
|
+
import { registerContentsTool } from './contents/register-contents-tool.ts';
|
|
6
|
+
import { registerExpressTool } from './express/register-express-tool.ts';
|
|
7
|
+
import { getMCpServer } from './get-mcp-server.ts';
|
|
8
|
+
import { registerSearchTool } from './search/register-search-tool.ts';
|
|
9
|
+
import { useGetClientVersion } from './shared/use-client-version.ts';
|
|
10
|
+
|
|
11
|
+
const extractBearerToken = (authHeader: string | null): string | null => {
|
|
12
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return authHeader.slice(7);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const handleMcpRequest = async (c: Context) => {
|
|
19
|
+
const authHeader = c.req.header('Authorization');
|
|
20
|
+
|
|
21
|
+
if (!authHeader) {
|
|
22
|
+
c.status(401);
|
|
23
|
+
c.header('Content-Type', 'text/plain');
|
|
24
|
+
return c.text('Unauthorized: Authorization header required');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const YDC_API_KEY = extractBearerToken(authHeader);
|
|
28
|
+
|
|
29
|
+
if (!YDC_API_KEY) {
|
|
30
|
+
c.status(401);
|
|
31
|
+
c.header('Content-Type', 'text/plain');
|
|
32
|
+
return c.text('Unauthorized: Bearer token required');
|
|
33
|
+
}
|
|
34
|
+
const mcp = getMCpServer();
|
|
35
|
+
const getUserAgent = useGetClientVersion(mcp);
|
|
36
|
+
|
|
37
|
+
registerSearchTool({
|
|
38
|
+
mcp,
|
|
39
|
+
YDC_API_KEY,
|
|
40
|
+
getUserAgent,
|
|
41
|
+
});
|
|
42
|
+
registerExpressTool({ mcp, YDC_API_KEY, getUserAgent });
|
|
43
|
+
registerContentsTool({ mcp, YDC_API_KEY, getUserAgent });
|
|
44
|
+
|
|
45
|
+
const transport = new StreamableHTTPTransport();
|
|
46
|
+
await mcp.connect(transport);
|
|
47
|
+
const response = await transport.handleRequest(c);
|
|
48
|
+
|
|
49
|
+
// Explicitly set Content-Encoding to 'identity' to prevent httpx auto-decompression issues
|
|
50
|
+
// httpx by default sends Accept-Encoding and attempts decompression, but MCP SSE streams
|
|
51
|
+
// are not compressed. Setting 'identity' tells clients the response is uncompressed.
|
|
52
|
+
response?.headers.set('Content-Encoding', 'identity');
|
|
53
|
+
|
|
54
|
+
return response;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const app = new Hono();
|
|
58
|
+
app.use(trimTrailingSlash());
|
|
59
|
+
|
|
60
|
+
app.get('/mcp-health', async (c) => {
|
|
61
|
+
return c.json({
|
|
62
|
+
status: 'healthy',
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
version: packageJson.version,
|
|
65
|
+
service: 'youdotcom-mcp-server',
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
app.all('/mcp', handleMcpRequest);
|
|
70
|
+
app.all('/mcp/', handleMcpRequest);
|
|
71
|
+
|
|
72
|
+
export default app;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { generateErrorReportLink } from '../shared/generate-error-report-link.ts';
|
|
3
|
+
import { getLogger } from '../shared/get-logger.ts';
|
|
4
|
+
import { SearchQuerySchema, SearchStructuredContentSchema } from './search.schemas.ts';
|
|
5
|
+
import { fetchSearchResults, formatSearchResults } from './search.utils.ts';
|
|
6
|
+
|
|
7
|
+
export const registerSearchTool = ({
|
|
8
|
+
mcp,
|
|
9
|
+
YDC_API_KEY,
|
|
10
|
+
getUserAgent,
|
|
11
|
+
}: {
|
|
12
|
+
mcp: McpServer;
|
|
13
|
+
YDC_API_KEY?: string;
|
|
14
|
+
getUserAgent: () => string;
|
|
15
|
+
}) => {
|
|
16
|
+
mcp.registerTool(
|
|
17
|
+
'you-search',
|
|
18
|
+
{
|
|
19
|
+
title: 'Web Search',
|
|
20
|
+
description: 'Web and news search via You.com',
|
|
21
|
+
inputSchema: SearchQuerySchema.shape,
|
|
22
|
+
outputSchema: SearchStructuredContentSchema.shape,
|
|
23
|
+
},
|
|
24
|
+
async (searchQuery) => {
|
|
25
|
+
const logger = getLogger(mcp);
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetchSearchResults({
|
|
28
|
+
searchQuery,
|
|
29
|
+
YDC_API_KEY,
|
|
30
|
+
getUserAgent,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const webCount = response.results.web?.length ?? 0;
|
|
34
|
+
const newsCount = response.results.news?.length ?? 0;
|
|
35
|
+
|
|
36
|
+
if (!webCount && !newsCount) {
|
|
37
|
+
await logger({
|
|
38
|
+
level: 'info',
|
|
39
|
+
data: `No results found for query: "${searchQuery.query}"`,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
content: [{ type: 'text' as const, text: 'No results found.' }],
|
|
44
|
+
structuredContent: {
|
|
45
|
+
resultCounts: {
|
|
46
|
+
web: 0,
|
|
47
|
+
news: 0,
|
|
48
|
+
total: 0,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await logger({
|
|
55
|
+
level: 'info',
|
|
56
|
+
data: `Search successful for query: "${searchQuery.query}" - ${webCount} web results, ${newsCount} news results (${webCount + newsCount} total)`,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const { content, structuredContent } = formatSearchResults(response);
|
|
60
|
+
return { content, structuredContent };
|
|
61
|
+
} catch (err: unknown) {
|
|
62
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
63
|
+
const reportLink = generateErrorReportLink({
|
|
64
|
+
errorMessage,
|
|
65
|
+
tool: 'you-search',
|
|
66
|
+
clientInfo: getUserAgent(),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await logger({
|
|
70
|
+
level: 'error',
|
|
71
|
+
data: `Search API call failed: ${errorMessage}\n\nReport this issue: ${reportLink}`,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
content: [
|
|
76
|
+
{
|
|
77
|
+
type: 'text' as const,
|
|
78
|
+
text: `Error: ${errorMessage}`,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
structuredContent: undefined,
|
|
82
|
+
isError: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { SearchResponse } from '../search.schemas.ts';
|
|
3
|
+
import { fetchSearchResults, formatSearchResults } from '../search.utils.ts';
|
|
4
|
+
|
|
5
|
+
const getUserAgent = () => 'MCP/test (You.com; test-client)';
|
|
6
|
+
|
|
7
|
+
describe('fetchSearchResults', () => {
|
|
8
|
+
test('returns valid response structure for basic query', async () => {
|
|
9
|
+
const result = await fetchSearchResults({
|
|
10
|
+
searchQuery: { query: 'latest stock news' },
|
|
11
|
+
getUserAgent,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
expect(result).toHaveProperty('results');
|
|
15
|
+
expect(result).toHaveProperty('metadata');
|
|
16
|
+
expect(result.results).toHaveProperty('web');
|
|
17
|
+
expect(result.results).toHaveProperty('news');
|
|
18
|
+
expect(Array.isArray(result.results.web)).toBe(true);
|
|
19
|
+
expect(Array.isArray(result.results.news)).toBe(true);
|
|
20
|
+
|
|
21
|
+
// Assert required metadata fields
|
|
22
|
+
expect(typeof result.metadata?.query).toBe('string');
|
|
23
|
+
|
|
24
|
+
// Optional fields: only assert type if present
|
|
25
|
+
if (result.metadata?.request_uuid !== undefined) {
|
|
26
|
+
expect(typeof result.metadata.request_uuid).toBe('string');
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('handles search with filters', async () => {
|
|
31
|
+
const result = await fetchSearchResults({
|
|
32
|
+
searchQuery: {
|
|
33
|
+
query: 'javascript tutorial',
|
|
34
|
+
count: 3,
|
|
35
|
+
freshness: 'week',
|
|
36
|
+
country: 'US',
|
|
37
|
+
},
|
|
38
|
+
getUserAgent,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(result.results.web?.length).toBeLessThanOrEqual(3);
|
|
42
|
+
expect(result.metadata?.query).toContain('javascript tutorial');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('validates response schema', async () => {
|
|
46
|
+
const result = await fetchSearchResults({
|
|
47
|
+
searchQuery: { query: 'latest technology news' },
|
|
48
|
+
getUserAgent,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Test that web results have required properties
|
|
52
|
+
// biome-ignore lint/style/noNonNullAssertion: Test
|
|
53
|
+
const webResult = result.results.web![0];
|
|
54
|
+
|
|
55
|
+
expect(webResult).toHaveProperty('url');
|
|
56
|
+
expect(webResult).toHaveProperty('title');
|
|
57
|
+
expect(webResult).toHaveProperty('description');
|
|
58
|
+
expect(webResult).toHaveProperty('snippets');
|
|
59
|
+
expect(Array.isArray(webResult?.snippets)).toBe(true);
|
|
60
|
+
|
|
61
|
+
// Test that news results have required properties
|
|
62
|
+
// biome-ignore lint/style/noNonNullAssertion: Test
|
|
63
|
+
const newsResult = result.results.news![0];
|
|
64
|
+
expect(newsResult).toHaveProperty('url');
|
|
65
|
+
expect(newsResult).toHaveProperty('title');
|
|
66
|
+
expect(newsResult).toHaveProperty('description');
|
|
67
|
+
expect(newsResult).toHaveProperty('page_age');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('formatSearchResults', () => {
|
|
72
|
+
test('formats web results correctly', () => {
|
|
73
|
+
const mockResponse: SearchResponse = {
|
|
74
|
+
results: {
|
|
75
|
+
web: [
|
|
76
|
+
{
|
|
77
|
+
url: 'https://example.com',
|
|
78
|
+
title: 'Test Title',
|
|
79
|
+
description: 'Test description',
|
|
80
|
+
snippets: ['snippet 1', 'snippet 2'],
|
|
81
|
+
page_age: '2023-01-01T00:00:00',
|
|
82
|
+
authors: ['Author Name'],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
news: [],
|
|
86
|
+
},
|
|
87
|
+
metadata: {
|
|
88
|
+
request_uuid: 'test-uuid',
|
|
89
|
+
query: 'test query',
|
|
90
|
+
latency: 0.1,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const result = formatSearchResults(mockResponse);
|
|
95
|
+
|
|
96
|
+
expect(result).toHaveProperty('content');
|
|
97
|
+
expect(result).toHaveProperty('structuredContent');
|
|
98
|
+
expect(result).toHaveProperty('fullResponse');
|
|
99
|
+
expect(Array.isArray(result.content)).toBe(true);
|
|
100
|
+
expect(result.content[0]).toHaveProperty('type', 'text');
|
|
101
|
+
expect(result.content[0]).toHaveProperty('text');
|
|
102
|
+
expect(result.content[0]?.text).toContain('WEB RESULTS:');
|
|
103
|
+
expect(result.content[0]?.text).toContain('Test Title');
|
|
104
|
+
// URLs should NOT be in text content
|
|
105
|
+
expect(result.content[0]?.text).not.toContain('https://example.com');
|
|
106
|
+
expect(result.structuredContent).toHaveProperty('resultCounts');
|
|
107
|
+
expect(result.structuredContent.resultCounts).toHaveProperty('web', 1);
|
|
108
|
+
expect(result.structuredContent.resultCounts).toHaveProperty('news', 0);
|
|
109
|
+
expect(result.structuredContent.resultCounts).toHaveProperty('total', 1);
|
|
110
|
+
// URLs should be in structuredContent.results
|
|
111
|
+
expect(result.structuredContent).toHaveProperty('results');
|
|
112
|
+
expect(result.structuredContent.results?.web).toBeDefined();
|
|
113
|
+
expect(result.structuredContent.results?.web?.length).toBe(1);
|
|
114
|
+
expect(result.structuredContent.results?.web?.[0]).toEqual({
|
|
115
|
+
url: 'https://example.com',
|
|
116
|
+
title: 'Test Title',
|
|
117
|
+
});
|
|
118
|
+
expect(result.fullResponse).toBe(mockResponse);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('formats news results correctly', () => {
|
|
122
|
+
const mockResponse: SearchResponse = {
|
|
123
|
+
results: {
|
|
124
|
+
web: [],
|
|
125
|
+
news: [
|
|
126
|
+
{
|
|
127
|
+
title: 'News Title',
|
|
128
|
+
description: 'News description',
|
|
129
|
+
page_age: '2023-01-01T00:00:00',
|
|
130
|
+
url: 'https://news.com/article',
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
metadata: {
|
|
135
|
+
request_uuid: 'test-uuid',
|
|
136
|
+
query: 'test query',
|
|
137
|
+
latency: 0.1,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const result = formatSearchResults(mockResponse);
|
|
142
|
+
|
|
143
|
+
expect(result.content[0]?.text).toContain('NEWS RESULTS:');
|
|
144
|
+
expect(result.content[0]?.text).toContain('News Title');
|
|
145
|
+
expect(result.content[0]?.text).toContain('Published: 2023-01-01T00:00:00');
|
|
146
|
+
// URLs should NOT be in text content
|
|
147
|
+
expect(result.content[0]?.text).not.toContain('https://news.com/article');
|
|
148
|
+
expect(result.structuredContent).toHaveProperty('resultCounts');
|
|
149
|
+
expect(result.structuredContent.resultCounts).toHaveProperty('web', 0);
|
|
150
|
+
expect(result.structuredContent.resultCounts).toHaveProperty('news', 1);
|
|
151
|
+
expect(result.structuredContent.resultCounts).toHaveProperty('total', 1);
|
|
152
|
+
// URLs should be in structuredContent.results
|
|
153
|
+
expect(result.structuredContent).toHaveProperty('results');
|
|
154
|
+
expect(result.structuredContent.results?.news).toBeDefined();
|
|
155
|
+
expect(result.structuredContent.results?.news?.length).toBe(1);
|
|
156
|
+
expect(result.structuredContent.results?.news?.[0]).toEqual({
|
|
157
|
+
url: 'https://news.com/article',
|
|
158
|
+
title: 'News Title',
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('formats both web and news results', () => {
|
|
163
|
+
const mockResponse: SearchResponse = {
|
|
164
|
+
results: {
|
|
165
|
+
web: [
|
|
166
|
+
{
|
|
167
|
+
url: 'https://web.com',
|
|
168
|
+
title: 'Web Title',
|
|
169
|
+
description: 'Web description',
|
|
170
|
+
snippets: ['web snippet'],
|
|
171
|
+
page_age: '2023-01-01T00:00:00',
|
|
172
|
+
authors: ['Web Author'],
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
news: [
|
|
176
|
+
{
|
|
177
|
+
title: 'News Title',
|
|
178
|
+
description: 'News description',
|
|
179
|
+
page_age: '2023-01-01T00:00:00',
|
|
180
|
+
url: 'https://news.com/article',
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
metadata: {
|
|
185
|
+
request_uuid: 'test-uuid',
|
|
186
|
+
query: 'test query',
|
|
187
|
+
latency: 0.1,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const result = formatSearchResults(mockResponse);
|
|
192
|
+
|
|
193
|
+
expect(result.content[0]?.text).toContain('WEB RESULTS:');
|
|
194
|
+
expect(result.content[0]?.text).toContain('NEWS RESULTS:');
|
|
195
|
+
expect(result.content[0]?.text).toContain(`=${'='.repeat(49)}`);
|
|
196
|
+
// URLs should NOT be in text content
|
|
197
|
+
expect(result.content[0]?.text).not.toContain('https://web.com');
|
|
198
|
+
expect(result.content[0]?.text).not.toContain('https://news.com/article');
|
|
199
|
+
expect(result.structuredContent.resultCounts).toHaveProperty('web', 1);
|
|
200
|
+
expect(result.structuredContent.resultCounts).toHaveProperty('news', 1);
|
|
201
|
+
expect(result.structuredContent.resultCounts).toHaveProperty('total', 2);
|
|
202
|
+
// URLs should be in structuredContent.results
|
|
203
|
+
expect(result.structuredContent).toHaveProperty('results');
|
|
204
|
+
expect(result.structuredContent.results?.web).toBeDefined();
|
|
205
|
+
expect(result.structuredContent.results?.news).toBeDefined();
|
|
206
|
+
expect(result.structuredContent.results?.web?.length).toBe(1);
|
|
207
|
+
expect(result.structuredContent.results?.news?.length).toBe(1);
|
|
208
|
+
expect(result.structuredContent.results?.web?.[0]).toEqual({
|
|
209
|
+
url: 'https://web.com',
|
|
210
|
+
title: 'Web Title',
|
|
211
|
+
});
|
|
212
|
+
expect(result.structuredContent.results?.news?.[0]).toEqual({
|
|
213
|
+
url: 'https://news.com/article',
|
|
214
|
+
title: 'News Title',
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import packageJson from '../../package.json' with { type: 'json' };
|
|
2
|
+
/**
|
|
3
|
+
* Generates a mailto link for error reporting with pre-filled context
|
|
4
|
+
* Used by tool error handlers to provide easy error reporting
|
|
5
|
+
*/
|
|
6
|
+
export const generateErrorReportLink = ({
|
|
7
|
+
errorMessage,
|
|
8
|
+
tool,
|
|
9
|
+
clientInfo,
|
|
10
|
+
}: {
|
|
11
|
+
errorMessage: string;
|
|
12
|
+
tool: string;
|
|
13
|
+
clientInfo: string;
|
|
14
|
+
}): string => {
|
|
15
|
+
const subject = `MCP Server Issue v${packageJson.version}`;
|
|
16
|
+
const body = `Server Version: v${packageJson.version}
|
|
17
|
+
Client: ${clientInfo}
|
|
18
|
+
Tool: ${tool}
|
|
19
|
+
|
|
20
|
+
Error Message:
|
|
21
|
+
${errorMessage}
|
|
22
|
+
|
|
23
|
+
Steps to Reproduce:
|
|
24
|
+
1.
|
|
25
|
+
2.
|
|
26
|
+
3.
|
|
27
|
+
|
|
28
|
+
Additional Context:
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
const params = new URLSearchParams({
|
|
32
|
+
subject,
|
|
33
|
+
body,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return `mailto:support@you.com?${params.toString()}`;
|
|
37
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { LoggingMessageNotification } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a logger function that sends messages through MCP server
|
|
6
|
+
* Used by tool registration files
|
|
7
|
+
*/
|
|
8
|
+
export const getLogger = (mcp: McpServer) => async (params: LoggingMessageNotification['params']) => {
|
|
9
|
+
await mcp.server.sendLoggingMessage(params);
|
|
10
|
+
};
|