@youdotcom-oss/mcp 3.2.3 → 3.4.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.
@@ -1,128 +0,0 @@
1
- import { describe, expect, test } from 'bun:test'
2
- import type { ResearchResponse } from '@youdotcom-oss/api'
3
- import { formatResearchResults } from '../research.utils.ts'
4
-
5
- describe('formatResearchResults', () => {
6
- test('formats research response with sources correctly', () => {
7
- const mockResponse: ResearchResponse = {
8
- output: {
9
- content: '# Research Answer\n\nThis is a comprehensive answer about the topic.',
10
- content_type: 'text',
11
- sources: [
12
- {
13
- url: 'https://example.com/source1',
14
- title: 'Source One',
15
- snippets: ['First snippet', 'Second snippet'],
16
- },
17
- {
18
- url: 'https://example.com/source2',
19
- title: 'Source Two',
20
- snippets: ['Another snippet'],
21
- },
22
- ],
23
- },
24
- }
25
-
26
- const result = formatResearchResults(mockResponse)
27
-
28
- expect(result).toHaveProperty('content')
29
- expect(result).toHaveProperty('structuredContent')
30
- expect(Array.isArray(result.content)).toBe(true)
31
- expect(result.content[0]).toHaveProperty('type', 'text')
32
- expect(result.content[0]).toHaveProperty('text')
33
-
34
- const text = result.content[0]?.text
35
- expect(text).toContain('Research Answer')
36
- expect(text).toContain('Source One')
37
- expect(text).toContain('https://example.com/source1')
38
-
39
- expect(result.structuredContent.contentType).toBe('text')
40
- expect(result.structuredContent.sourceCount).toBe(2)
41
- expect(result.structuredContent.sources).toHaveLength(2)
42
- expect(result.structuredContent.sources[0]).toMatchObject({
43
- url: 'https://example.com/source1',
44
- title: 'Source One',
45
- snippetCount: 2,
46
- })
47
- expect(result.structuredContent.sources[1]).toMatchObject({
48
- url: 'https://example.com/source2',
49
- title: 'Source Two',
50
- snippetCount: 1,
51
- })
52
- })
53
-
54
- test('handles source with undefined title', () => {
55
- const mockResponse: ResearchResponse = {
56
- output: {
57
- content: 'Answer text',
58
- content_type: 'text',
59
- sources: [
60
- {
61
- url: 'https://example.com/no-title',
62
- snippets: ['A snippet'],
63
- },
64
- ],
65
- },
66
- }
67
-
68
- const result = formatResearchResults(mockResponse)
69
-
70
- expect(result.structuredContent.sourceCount).toBe(1)
71
- expect(result.structuredContent.sources[0]).toMatchObject({
72
- url: 'https://example.com/no-title',
73
- title: undefined,
74
- snippetCount: 1,
75
- })
76
- })
77
-
78
- test('handles source with empty snippets array', () => {
79
- const mockResponse: ResearchResponse = {
80
- output: {
81
- content: 'Answer with no-snippet source',
82
- content_type: 'text',
83
- sources: [
84
- {
85
- url: 'https://example.com/empty-snippets',
86
- title: 'Empty Snippets Source',
87
- snippets: [],
88
- },
89
- ],
90
- },
91
- }
92
-
93
- const result = formatResearchResults(mockResponse)
94
-
95
- expect(result.structuredContent.sourceCount).toBe(1)
96
- expect(result.structuredContent.sources[0]?.snippetCount).toBe(0)
97
- })
98
-
99
- test('handles source with undefined snippets', () => {
100
- const mockResponse: ResearchResponse = {
101
- output: {
102
- content: 'Answer',
103
- content_type: 'text',
104
- sources: [{ url: 'https://example.com/no-snippets', title: 'No Snippets' }],
105
- },
106
- }
107
-
108
- const result = formatResearchResults(mockResponse)
109
-
110
- expect(result.structuredContent.sources[0]?.snippetCount).toBe(0)
111
- })
112
-
113
- test('handles response with zero sources', () => {
114
- const mockResponse: ResearchResponse = {
115
- output: {
116
- content: 'An answer with no cited sources.',
117
- content_type: 'text',
118
- sources: [],
119
- },
120
- }
121
-
122
- const result = formatResearchResults(mockResponse)
123
-
124
- expect(result.structuredContent.sourceCount).toBe(0)
125
- expect(result.structuredContent.sources).toHaveLength(0)
126
- expect(result.content[0]?.text).toContain('An answer with no cited sources.')
127
- })
128
- })
@@ -1,88 +0,0 @@
1
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
- import { fetchSearchResults, generateErrorReportLink, SearchQuerySchema } from '@youdotcom-oss/api'
3
- import { getLogger } from '../shared/get-logger.ts'
4
- import { SearchStructuredContentSchema } from './search.schemas.ts'
5
- import { 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:
21
- 'Web and news search via You.com. Supports domain filtering, language selection, livecrawl for full page content, and date freshness controls.',
22
- inputSchema: SearchQuerySchema.shape,
23
- outputSchema: SearchStructuredContentSchema.shape,
24
- },
25
- async (searchQuery, { sendNotification }) => {
26
- const logger = getLogger(sendNotification)
27
- try {
28
- const response = await fetchSearchResults({
29
- searchQuery,
30
- YDC_API_KEY,
31
- getUserAgent,
32
- })
33
-
34
- const webCount = response.results.web?.length ?? 0
35
- const newsCount = response.results.news?.length ?? 0
36
-
37
- if (!webCount && !newsCount) {
38
- await logger({
39
- level: 'info',
40
- data: `No results found for query: "${searchQuery.query}"`,
41
- })
42
-
43
- return {
44
- content: [{ type: 'text' as const, text: 'No results found.' }],
45
- structuredContent: {
46
- resultCounts: {
47
- web: 0,
48
- news: 0,
49
- total: 0,
50
- },
51
- },
52
- }
53
- }
54
-
55
- await logger({
56
- level: 'info',
57
- data: `Search successful for query: "${searchQuery.query}" - ${webCount} web results, ${newsCount} news results (${webCount + newsCount} total)`,
58
- })
59
-
60
- const { content, structuredContent } = formatSearchResults(response)
61
- return { content, structuredContent }
62
- } catch (err: unknown) {
63
- const errorMessage = err instanceof Error ? err.message : String(err)
64
- const reportLink = generateErrorReportLink({
65
- errorMessage,
66
- tool: 'you-search',
67
- clientInfo: getUserAgent(),
68
- })
69
-
70
- await logger({
71
- level: 'error',
72
- data: `Search API call failed: ${errorMessage}\n\nReport this issue: ${reportLink}`,
73
- })
74
-
75
- return {
76
- content: [
77
- {
78
- type: 'text' as const,
79
- text: `Error: ${errorMessage}`,
80
- },
81
- ],
82
- structuredContent: undefined,
83
- isError: true,
84
- }
85
- }
86
- },
87
- )
88
- }
@@ -1,53 +0,0 @@
1
- import * as z from 'zod'
2
-
3
- // Minimal schema for structuredContent (reduces payload duplication)
4
- // Excludes metadata (query, search_uuid, latency) as these are not actionable by LLM
5
- export const SearchStructuredContentSchema = z.object({
6
- resultCounts: z.object({
7
- web: z.number().describe('Web results'),
8
- news: z.number().describe('News results'),
9
- total: z.number().describe('Total results'),
10
- }),
11
- results: z
12
- .object({
13
- web: z
14
- .array(
15
- z.object({
16
- url: z.string().describe('URL'),
17
- title: z.string().describe('Title'),
18
- page_age: z.string().optional().describe('Publication timestamp'),
19
- snippets: z.array(z.string()).optional().describe('Content snippets'),
20
- contents: z
21
- .object({
22
- html: z.string().optional().describe('Full HTML content'),
23
- markdown: z.string().optional().describe('Full Markdown content'),
24
- })
25
- .optional()
26
- .describe('Livecrawled page content'),
27
- }),
28
- )
29
- .optional()
30
- .describe('Web results'),
31
- news: z
32
- .array(
33
- z.object({
34
- url: z.string().describe('URL'),
35
- title: z.string().describe('Title'),
36
- page_age: z.string().describe('Publication timestamp'),
37
- contents: z
38
- .object({
39
- html: z.string().optional().describe('Full HTML content'),
40
- markdown: z.string().optional().describe('Full Markdown content'),
41
- })
42
- .optional()
43
- .describe('Livecrawled page content'),
44
- }),
45
- )
46
- .optional()
47
- .describe('News results'),
48
- })
49
- .optional()
50
- .describe('Search results'),
51
- })
52
-
53
- export type SearchStructuredContent = z.infer<typeof SearchStructuredContentSchema>
@@ -1,83 +0,0 @@
1
- import type { SearchResponse } from '@youdotcom-oss/api'
2
- import { formatSearchResultsText } from '../shared/format-search-results-text.ts'
3
-
4
- export const formatSearchResults = (response: SearchResponse) => {
5
- let formattedResults = ''
6
-
7
- // Format web results using shared utility
8
- if (response.results.web?.length) {
9
- const webResults = formatSearchResultsText(response.results.web)
10
- formattedResults += `WEB RESULTS:\n\n${webResults}`
11
- }
12
-
13
- // Format news results using shared utility (consistent with web formatting)
14
- if (response.results.news?.length) {
15
- const newsResults = formatSearchResultsText(response.results.news)
16
-
17
- if (formattedResults) {
18
- formattedResults += `\n\n${'='.repeat(50)}\n\n`
19
- }
20
- formattedResults += `NEWS RESULTS:\n\n${newsResults}`
21
- }
22
-
23
- // Extract fields for structuredContent
24
- const structuredResults: {
25
- web?: Array<{
26
- url: string
27
- title: string
28
- page_age?: string
29
- snippets?: string[]
30
- contents?: { html?: string; markdown?: string }
31
- }>
32
- news?: Array<{ url: string; title: string; page_age: string; contents?: { html?: string; markdown?: string } }>
33
- } = {}
34
-
35
- if (response.results.web?.length) {
36
- structuredResults.web = response.results.web.map((result) => {
37
- const item: {
38
- url: string
39
- title: string
40
- page_age?: string
41
- snippets?: string[]
42
- contents?: { html?: string; markdown?: string }
43
- } = {
44
- url: result.url,
45
- title: result.title,
46
- }
47
- if (result.page_age) item.page_age = result.page_age
48
- if (result.snippets?.length) item.snippets = result.snippets
49
- if (result.contents) item.contents = result.contents ?? undefined
50
- return item
51
- })
52
- }
53
-
54
- if (response.results.news?.length) {
55
- structuredResults.news = response.results.news.map((article) => {
56
- const item: { url: string; title: string; page_age: string; contents?: { html?: string; markdown?: string } } = {
57
- url: article.url,
58
- title: article.title,
59
- page_age: article.page_age,
60
- }
61
- if (article.contents) item.contents = article.contents ?? undefined
62
- return item
63
- })
64
- }
65
-
66
- return {
67
- content: [
68
- {
69
- type: 'text' as const,
70
- text: `Search Results for "${response.metadata.query}":\n\n${formattedResults}`,
71
- },
72
- ],
73
- structuredContent: {
74
- resultCounts: {
75
- web: response.results.web?.length || 0,
76
- news: response.results.news?.length || 0,
77
- total: (response.results.web?.length || 0) + (response.results.news?.length || 0),
78
- },
79
- results: Object.keys(structuredResults).length > 0 ? structuredResults : undefined,
80
- },
81
- fullResponse: response,
82
- }
83
- }
@@ -1,123 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'
2
- import { Client } from '@modelcontextprotocol/sdk/client/index.js'
3
- import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
4
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5
- import type { SearchResponse } from '@youdotcom-oss/api'
6
- import * as api from '@youdotcom-oss/api'
7
- import { registerSearchTool } from '../register-search-tool.ts'
8
-
9
- const emptyResponse: SearchResponse = {
10
- results: { web: [], news: [] },
11
- metadata: { search_uuid: 'test', query: 'test', latency: 0 },
12
- }
13
-
14
- const oneResultResponse: SearchResponse = {
15
- results: {
16
- web: [
17
- {
18
- url: 'https://example.com',
19
- title: 'Example',
20
- description: 'A test result',
21
- snippets: ['snippet'],
22
- page_age: '2025-01-01T00:00:00',
23
- authors: [],
24
- },
25
- ],
26
- news: [],
27
- },
28
- metadata: { search_uuid: 'test', query: 'test', latency: 0.1 },
29
- }
30
-
31
- let mockFetchResponse: SearchResponse | Error = emptyResponse
32
- let fetchSearchResultsSpy: ReturnType<typeof spyOn<typeof api, 'fetchSearchResults'>> | undefined
33
- let generateErrorReportLinkSpy: ReturnType<typeof spyOn<typeof api, 'generateErrorReportLink'>> | undefined
34
-
35
- type Cleanup = () => Promise<void>
36
-
37
- const setupMcpClient = async (): Promise<{ client: Client; cleanup: Cleanup }> => {
38
- const server = new McpServer({ name: 'test', version: '0.0.0' }, { capabilities: { logging: {}, tools: {} } })
39
- registerSearchTool({
40
- mcp: server,
41
- YDC_API_KEY: 'test-key',
42
- getUserAgent: () => 'test-agent',
43
- })
44
-
45
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
46
- await server.connect(serverTransport)
47
-
48
- const client = new Client({ name: 'test-client', version: '0.0.0' })
49
- await client.connect(clientTransport)
50
-
51
- const cleanup = async () => {
52
- await client.close()
53
- await server.close()
54
- }
55
-
56
- return { client, cleanup }
57
- }
58
-
59
- describe('registerSearchTool', () => {
60
- let cleanup: Cleanup | undefined
61
-
62
- beforeEach(() => {
63
- mockFetchResponse = emptyResponse
64
- fetchSearchResultsSpy = spyOn(api, 'fetchSearchResults').mockImplementation(async () => {
65
- if (mockFetchResponse instanceof Error) throw mockFetchResponse
66
- return mockFetchResponse
67
- })
68
- generateErrorReportLinkSpy = spyOn(api, 'generateErrorReportLink').mockImplementation(
69
- () => 'https://example.com/report',
70
- )
71
- })
72
-
73
- afterEach(async () => {
74
- if (cleanup) {
75
- await cleanup()
76
- cleanup = undefined
77
- }
78
- fetchSearchResultsSpy?.mockRestore()
79
- fetchSearchResultsSpy = undefined
80
- generateErrorReportLinkSpy?.mockRestore()
81
- generateErrorReportLinkSpy = undefined
82
- })
83
-
84
- test('handles empty search results gracefully', async () => {
85
- const result = await setupMcpClient()
86
- cleanup = result.cleanup
87
-
88
- const toolResult = await result.client.callTool({ name: 'you-search', arguments: { query: 'nonexistent' } })
89
-
90
- expect(toolResult.content).toEqual([{ type: 'text', text: 'No results found.' }])
91
- expect(toolResult.structuredContent).toEqual({
92
- resultCounts: { web: 0, news: 0, total: 0 },
93
- })
94
- })
95
-
96
- test('returns formatted results for successful search', async () => {
97
- mockFetchResponse = oneResultResponse
98
- const result = await setupMcpClient()
99
- cleanup = result.cleanup
100
-
101
- const toolResult = await result.client.callTool({ name: 'you-search', arguments: { query: 'example' } })
102
-
103
- const text = (toolResult.content as Array<{ type: string; text: string }>)[0]?.text
104
- expect(text).toContain('Example')
105
- expect(text).toContain('https://example.com')
106
-
107
- const structured = toolResult.structuredContent as Record<string, unknown>
108
- expect(structured).toHaveProperty('resultCounts')
109
- expect((structured as { resultCounts: { total: number } }).resultCounts.total).toBe(1)
110
- })
111
-
112
- test('returns error when API call fails', async () => {
113
- mockFetchResponse = new Error('API rate limit exceeded')
114
- const result = await setupMcpClient()
115
- cleanup = result.cleanup
116
-
117
- const toolResult = await result.client.callTool({ name: 'you-search', arguments: { query: 'test' } })
118
-
119
- expect(toolResult.isError).toBe(true)
120
- const text = (toolResult.content as Array<{ type: string; text: string }>)[0]?.text
121
- expect(text).toContain('API rate limit exceeded')
122
- })
123
- })