@youdotcom-oss/mcp 1.3.2 → 1.3.3
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/AGENTS.md +149 -33
- package/README.md +60 -323
- package/bin/stdio.js +59 -41
- package/package.json +14 -4
- package/src/contents/contents.schemas.ts +55 -0
- package/src/contents/contents.utils.ts +145 -0
- package/src/express/express.schemas.ts +99 -0
- package/src/express/express.utils.ts +157 -0
- package/src/search/search.schemas.ts +126 -0
- package/src/search/search.utils.ts +142 -0
- package/src/shared/check-response-for-errors.ts +13 -0
- package/src/shared/format-search-results-text.ts +41 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
|
|
3
|
+
export const SearchQuerySchema = z.object({
|
|
4
|
+
query: z.string().min(1, 'Query is required').describe('Search query (supports +, -, site:, filetype:, lang:)'),
|
|
5
|
+
count: z.number().int().min(1).max(20).optional().describe('Max results per section'),
|
|
6
|
+
freshness: z.enum(['day', 'week', 'month', 'year']).optional().describe('Filter by freshness'),
|
|
7
|
+
offset: z.number().int().min(0).max(9).optional().describe('Pagination offset'),
|
|
8
|
+
country: z
|
|
9
|
+
.enum([
|
|
10
|
+
'AR',
|
|
11
|
+
'AU',
|
|
12
|
+
'AT',
|
|
13
|
+
'BE',
|
|
14
|
+
'BR',
|
|
15
|
+
'CA',
|
|
16
|
+
'CL',
|
|
17
|
+
'DK',
|
|
18
|
+
'FI',
|
|
19
|
+
'FR',
|
|
20
|
+
'DE',
|
|
21
|
+
'HK',
|
|
22
|
+
'IN',
|
|
23
|
+
'ID',
|
|
24
|
+
'IT',
|
|
25
|
+
'JP',
|
|
26
|
+
'KR',
|
|
27
|
+
'MY',
|
|
28
|
+
'MX',
|
|
29
|
+
'NL',
|
|
30
|
+
'NZ',
|
|
31
|
+
'NO',
|
|
32
|
+
'CN',
|
|
33
|
+
'PL',
|
|
34
|
+
'PT',
|
|
35
|
+
'PH',
|
|
36
|
+
'RU',
|
|
37
|
+
'SA',
|
|
38
|
+
'ZA',
|
|
39
|
+
'ES',
|
|
40
|
+
'SE',
|
|
41
|
+
'CH',
|
|
42
|
+
'TW',
|
|
43
|
+
'TR',
|
|
44
|
+
'GB',
|
|
45
|
+
'US',
|
|
46
|
+
])
|
|
47
|
+
.optional()
|
|
48
|
+
.describe('Country code'),
|
|
49
|
+
safesearch: z.enum(['off', 'moderate', 'strict']).optional().describe('Filter level'),
|
|
50
|
+
site: z.string().optional().describe('Specific domain'),
|
|
51
|
+
fileType: z.string().optional().describe('File type'),
|
|
52
|
+
language: z.string().optional().describe('ISO 639-1 language code'),
|
|
53
|
+
excludeTerms: z.string().optional().describe('Terms to exclude (pipe-separated)'),
|
|
54
|
+
exactTerms: z.string().optional().describe('Exact terms (pipe-separated)'),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export type SearchQuery = z.infer<typeof SearchQuerySchema>;
|
|
58
|
+
|
|
59
|
+
const WebResultSchema = z.object({
|
|
60
|
+
url: z.string().describe('URL'),
|
|
61
|
+
title: z.string().describe('Title'),
|
|
62
|
+
description: z.string().describe('Description'),
|
|
63
|
+
snippets: z.array(z.string()).describe('Content snippets'),
|
|
64
|
+
page_age: z.string().optional().describe('Publication timestamp'),
|
|
65
|
+
authors: z.array(z.string()).optional().describe('Authors'),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const NewsResultSchema = z.object({
|
|
69
|
+
title: z.string().describe('Title'),
|
|
70
|
+
description: z.string().describe('Description'),
|
|
71
|
+
page_age: z.string().describe('Publication timestamp'),
|
|
72
|
+
url: z.string().describe('URL'),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export type NewsResult = z.infer<typeof NewsResultSchema>;
|
|
76
|
+
|
|
77
|
+
const MetadataSchema = z.object({
|
|
78
|
+
request_uuid: z.string().optional().describe('Request ID'),
|
|
79
|
+
query: z.string().describe('Query'),
|
|
80
|
+
latency: z.number().describe('Latency in seconds'),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export const SearchResponseSchema = z.object({
|
|
84
|
+
results: z.object({
|
|
85
|
+
web: z.array(WebResultSchema).optional(),
|
|
86
|
+
news: z.array(NewsResultSchema).optional(),
|
|
87
|
+
}),
|
|
88
|
+
metadata: MetadataSchema.partial(),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export type SearchResponse = z.infer<typeof SearchResponseSchema>;
|
|
92
|
+
|
|
93
|
+
// Minimal schema for structuredContent (reduces payload duplication)
|
|
94
|
+
// Excludes metadata (query, request_uuid, latency) as these are not actionable by LLM
|
|
95
|
+
export const SearchStructuredContentSchema = z.object({
|
|
96
|
+
resultCounts: z.object({
|
|
97
|
+
web: z.number().describe('Web results'),
|
|
98
|
+
news: z.number().describe('News results'),
|
|
99
|
+
total: z.number().describe('Total results'),
|
|
100
|
+
}),
|
|
101
|
+
results: z
|
|
102
|
+
.object({
|
|
103
|
+
web: z
|
|
104
|
+
.array(
|
|
105
|
+
z.object({
|
|
106
|
+
url: z.string().describe('URL'),
|
|
107
|
+
title: z.string().describe('Title'),
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
.optional()
|
|
111
|
+
.describe('Web results'),
|
|
112
|
+
news: z
|
|
113
|
+
.array(
|
|
114
|
+
z.object({
|
|
115
|
+
url: z.string().describe('URL'),
|
|
116
|
+
title: z.string().describe('Title'),
|
|
117
|
+
}),
|
|
118
|
+
)
|
|
119
|
+
.optional()
|
|
120
|
+
.describe('News results'),
|
|
121
|
+
})
|
|
122
|
+
.optional()
|
|
123
|
+
.describe('Search results'),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export type SearchStructuredContent = z.infer<typeof SearchStructuredContentSchema>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { checkResponseForErrors } from '../shared/check-response-for-errors.ts';
|
|
2
|
+
import { formatSearchResultsText } from '../shared/format-search-results-text.ts';
|
|
3
|
+
import { type NewsResult, type SearchQuery, type SearchResponse, SearchResponseSchema } from './search.schemas.ts';
|
|
4
|
+
|
|
5
|
+
export const fetchSearchResults = async ({
|
|
6
|
+
YDC_API_KEY = process.env.YDC_API_KEY,
|
|
7
|
+
searchQuery: { query, site, fileType, language, exactTerms, excludeTerms, ...rest },
|
|
8
|
+
getUserAgent,
|
|
9
|
+
}: {
|
|
10
|
+
searchQuery: SearchQuery;
|
|
11
|
+
YDC_API_KEY?: string;
|
|
12
|
+
getUserAgent: () => string;
|
|
13
|
+
}) => {
|
|
14
|
+
const url = new URL('https://ydc-index.io/v1/search');
|
|
15
|
+
|
|
16
|
+
const searchParams = new URLSearchParams();
|
|
17
|
+
|
|
18
|
+
// Build Query Param
|
|
19
|
+
const searchQuery = [query];
|
|
20
|
+
site && searchQuery.push(`site:${site}`);
|
|
21
|
+
fileType && searchQuery.push(`fileType:${fileType}`);
|
|
22
|
+
language && searchQuery.push(`lang:${language}`);
|
|
23
|
+
if (exactTerms && excludeTerms) {
|
|
24
|
+
throw new Error('Cannot specify both exactTerms and excludeTerms - please use only one');
|
|
25
|
+
}
|
|
26
|
+
exactTerms &&
|
|
27
|
+
searchQuery.push(
|
|
28
|
+
exactTerms
|
|
29
|
+
.split('|')
|
|
30
|
+
.map((term) => `+${term}`)
|
|
31
|
+
.join(' AND '),
|
|
32
|
+
);
|
|
33
|
+
excludeTerms &&
|
|
34
|
+
searchQuery.push(
|
|
35
|
+
excludeTerms
|
|
36
|
+
.split('|')
|
|
37
|
+
.map((term) => `-${term}`)
|
|
38
|
+
.join(' AND '),
|
|
39
|
+
);
|
|
40
|
+
searchParams.append('query', searchQuery.join(' '));
|
|
41
|
+
|
|
42
|
+
// Append additional advanced Params
|
|
43
|
+
for (const [name, value] of Object.entries(rest)) {
|
|
44
|
+
if (value) searchParams.append(name, `${value}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
url.search = searchParams.toString();
|
|
48
|
+
|
|
49
|
+
const options = {
|
|
50
|
+
method: 'GET',
|
|
51
|
+
headers: new Headers({
|
|
52
|
+
'X-API-Key': YDC_API_KEY || '',
|
|
53
|
+
'User-Agent': getUserAgent(),
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const response = await fetch(url, options);
|
|
58
|
+
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
const errorCode = response.status;
|
|
61
|
+
|
|
62
|
+
if (errorCode === 429) {
|
|
63
|
+
throw new Error('Rate limited by You.com API. Please try again later.');
|
|
64
|
+
} else if (errorCode === 403) {
|
|
65
|
+
throw new Error('Forbidden. Please check your You.com API key.');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
throw new Error(`Failed to perform search. Error code: ${errorCode}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const results = await response.json();
|
|
72
|
+
|
|
73
|
+
// Check for error field in 200 responses (e.g., API limit errors)
|
|
74
|
+
checkResponseForErrors(results);
|
|
75
|
+
|
|
76
|
+
const parsedResults = SearchResponseSchema.parse(results);
|
|
77
|
+
|
|
78
|
+
return parsedResults;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const formatSearchResults = (response: SearchResponse) => {
|
|
82
|
+
let formattedResults = '';
|
|
83
|
+
|
|
84
|
+
// Format web results using shared utility (without URLs in text)
|
|
85
|
+
if (response.results.web?.length) {
|
|
86
|
+
const webResults = formatSearchResultsText(response.results.web);
|
|
87
|
+
formattedResults += `WEB RESULTS:\n\n${webResults}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Format news results (without URLs in text)
|
|
91
|
+
if (response.results.news?.length) {
|
|
92
|
+
const newsResults = response.results.news
|
|
93
|
+
.map(
|
|
94
|
+
(article: NewsResult) =>
|
|
95
|
+
`Title: ${article.title}\nDescription: ${article.description}\nPublished: ${article.page_age}`,
|
|
96
|
+
)
|
|
97
|
+
.join('\n\n---\n\n');
|
|
98
|
+
|
|
99
|
+
if (formattedResults) {
|
|
100
|
+
formattedResults += `\n\n${'='.repeat(50)}\n\n`;
|
|
101
|
+
}
|
|
102
|
+
formattedResults += `NEWS RESULTS:\n\n${newsResults}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Extract URLs and titles for structuredContent
|
|
106
|
+
const structuredResults: {
|
|
107
|
+
web?: Array<{ url: string; title: string }>;
|
|
108
|
+
news?: Array<{ url: string; title: string }>;
|
|
109
|
+
} = {};
|
|
110
|
+
|
|
111
|
+
if (response.results.web?.length) {
|
|
112
|
+
structuredResults.web = response.results.web.map((result) => ({
|
|
113
|
+
url: result.url,
|
|
114
|
+
title: result.title,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (response.results.news?.length) {
|
|
119
|
+
structuredResults.news = response.results.news.map((article) => ({
|
|
120
|
+
url: article.url,
|
|
121
|
+
title: article.title,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
content: [
|
|
127
|
+
{
|
|
128
|
+
type: 'text' as const,
|
|
129
|
+
text: `Search Results for "${response.metadata.query}":\n\n${formattedResults}`,
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
structuredContent: {
|
|
133
|
+
resultCounts: {
|
|
134
|
+
web: response.results.web?.length || 0,
|
|
135
|
+
news: response.results.news?.length || 0,
|
|
136
|
+
total: (response.results.web?.length || 0) + (response.results.news?.length || 0),
|
|
137
|
+
},
|
|
138
|
+
results: Object.keys(structuredResults).length > 0 ? structuredResults : undefined,
|
|
139
|
+
},
|
|
140
|
+
fullResponse: response,
|
|
141
|
+
};
|
|
142
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks if a response object contains an error field and throws if found
|
|
3
|
+
* Handles API responses that return 200 status but contain error messages
|
|
4
|
+
* Used by both search and express agent utilities
|
|
5
|
+
*/
|
|
6
|
+
export const checkResponseForErrors = (responseData: unknown) => {
|
|
7
|
+
if (typeof responseData === 'object' && responseData !== null && 'error' in responseData) {
|
|
8
|
+
const errorMessage =
|
|
9
|
+
typeof responseData.error === 'string' ? responseData.error : JSON.stringify(responseData.error);
|
|
10
|
+
throw new Error(`You.com API Error: ${errorMessage}`);
|
|
11
|
+
}
|
|
12
|
+
return responseData;
|
|
13
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic search result type that works for both Search and Express APIs
|
|
3
|
+
* Used by both search.utils.ts and express.utils.ts
|
|
4
|
+
*/
|
|
5
|
+
type GenericSearchResult = {
|
|
6
|
+
url: string;
|
|
7
|
+
title: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
snippet?: string;
|
|
10
|
+
snippets?: string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Format array of search results into display text
|
|
15
|
+
* Used by both search and express agent formatting
|
|
16
|
+
* @param results - Array of search results to format
|
|
17
|
+
* @param includeUrls - Whether to include URLs in the text (default: true)
|
|
18
|
+
*/
|
|
19
|
+
export const formatSearchResultsText = (results: GenericSearchResult[]): string => {
|
|
20
|
+
return results
|
|
21
|
+
.map((result) => {
|
|
22
|
+
const parts: string[] = [`Title: ${result.title}`];
|
|
23
|
+
|
|
24
|
+
// Add description if present (from Search API)
|
|
25
|
+
if (result.description) {
|
|
26
|
+
parts.push(`Description: ${result.description}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Handle snippets array (from Search API)
|
|
30
|
+
if (result.snippets && result.snippets.length > 0) {
|
|
31
|
+
parts.push(`Snippets:\n- ${result.snippets.join('\n- ')}`);
|
|
32
|
+
}
|
|
33
|
+
// Handle single snippet (from Express API)
|
|
34
|
+
else if (result.snippet) {
|
|
35
|
+
parts.push(`Snippet: ${result.snippet}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return parts.join('\n');
|
|
39
|
+
})
|
|
40
|
+
.join('\n\n');
|
|
41
|
+
};
|