codewiki-mcp 1.0.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.
- package/.claude/skills/codewiki.md +186 -0
- package/.cursor/mcp.json +9 -0
- package/.dockerignore +10 -0
- package/.github/dependabot.yml +33 -0
- package/.github/workflows/ci.yml +42 -0
- package/.github/workflows/release.yml +36 -0
- package/.releaserc.json +16 -0
- package/CHANGELOG.md +6 -0
- package/Dockerfile +15 -0
- package/README.md +484 -0
- package/README.ru.md +484 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +59 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/batchexecute.d.ts +7 -0
- package/dist/lib/batchexecute.js +56 -0
- package/dist/lib/batchexecute.js.map +1 -0
- package/dist/lib/codewikiClient.d.ts +55 -0
- package/dist/lib/codewikiClient.js +207 -0
- package/dist/lib/codewikiClient.js.map +1 -0
- package/dist/lib/config.d.ts +8 -0
- package/dist/lib/config.js +22 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/errors.d.ts +26 -0
- package/dist/lib/errors.js +40 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/extractKeyword.d.ts +1 -0
- package/dist/lib/extractKeyword.js +41 -0
- package/dist/lib/extractKeyword.js.map +1 -0
- package/dist/lib/repo.d.ts +9 -0
- package/dist/lib/repo.js +51 -0
- package/dist/lib/repo.js.map +1 -0
- package/dist/lib/resolveRepo.d.ts +2 -0
- package/dist/lib/resolveRepo.js +44 -0
- package/dist/lib/resolveRepo.js.map +1 -0
- package/dist/schemas.d.ts +50 -0
- package/dist/schemas.js +18 -0
- package/dist/schemas.js.map +1 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +94 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/askRepo.d.ts +4 -0
- package/dist/tools/askRepo.js +29 -0
- package/dist/tools/askRepo.js.map +1 -0
- package/dist/tools/fetchRepo.d.ts +4 -0
- package/dist/tools/fetchRepo.js +62 -0
- package/dist/tools/fetchRepo.js.map +1 -0
- package/dist/tools/searchRepos.d.ts +3 -0
- package/dist/tools/searchRepos.js +32 -0
- package/dist/tools/searchRepos.js.map +1 -0
- package/package.json +37 -0
- package/src/cli.ts +67 -0
- package/src/index.ts +32 -0
- package/src/lib/batchexecute.ts +72 -0
- package/src/lib/codewikiClient.ts +294 -0
- package/src/lib/config.ts +30 -0
- package/src/lib/errors.ts +57 -0
- package/src/lib/extractKeyword.ts +47 -0
- package/src/lib/repo.ts +70 -0
- package/src/lib/resolveRepo.ts +60 -0
- package/src/schemas.ts +22 -0
- package/src/server.ts +120 -0
- package/src/tools/askRepo.ts +38 -0
- package/src/tools/fetchRepo.ts +74 -0
- package/src/tools/searchRepos.ts +40 -0
- package/tests/batchexecute.test.ts +42 -0
- package/tests/client.test.ts +129 -0
- package/tests/errors.test.ts +122 -0
- package/tests/extractKeyword.test.ts +34 -0
- package/tests/resolveRepo.test.ts +79 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
+
import type { AskHistoryItem, CodeWikiClient } from '../lib/codewikiClient.js'
|
|
3
|
+
import type { CodeWikiConfig } from '../lib/config.js'
|
|
4
|
+
import { formatMcpError } from '../lib/errors.js'
|
|
5
|
+
import { resolveRepoInput } from '../lib/repo.js'
|
|
6
|
+
import { AskRepoInput } from '../schemas.js'
|
|
7
|
+
|
|
8
|
+
export function registerAskRepoTool(mcp: McpServer, client: CodeWikiClient, config?: CodeWikiConfig): void {
|
|
9
|
+
mcp.tool(
|
|
10
|
+
'codewiki_ask_repo',
|
|
11
|
+
'Ask a natural-language question about a repository indexed in codewiki.google',
|
|
12
|
+
AskRepoInput.shape,
|
|
13
|
+
async (rawInput) => {
|
|
14
|
+
const parsed = AskRepoInput.safeParse(rawInput)
|
|
15
|
+
if (!parsed.success) {
|
|
16
|
+
return {
|
|
17
|
+
content: [{ type: 'text', text: `Invalid arguments: ${parsed.error.message}` }],
|
|
18
|
+
isError: true,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const { repo, question, history } = parsed.data
|
|
24
|
+
const resolved = await resolveRepoInput(repo, config)
|
|
25
|
+
const { data: answer, meta } = await client.askRepository(resolved.repoUrl, question, history as AskHistoryItem[] | undefined)
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
content: [{
|
|
29
|
+
type: 'text',
|
|
30
|
+
text: JSON.stringify({ answer, meta }, null, 2),
|
|
31
|
+
}],
|
|
32
|
+
}
|
|
33
|
+
} catch (err) {
|
|
34
|
+
return formatMcpError(err)
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
+
import type { CodeWikiClient, WikiSection } from '../lib/codewikiClient.js'
|
|
3
|
+
import type { CodeWikiConfig } from '../lib/config.js'
|
|
4
|
+
import { formatMcpError } from '../lib/errors.js'
|
|
5
|
+
import { resolveRepoInput } from '../lib/repo.js'
|
|
6
|
+
import { FetchRepoInput } from '../schemas.js'
|
|
7
|
+
|
|
8
|
+
function toSectionMarkdown(section: WikiSection): string {
|
|
9
|
+
const depth = Math.max(1, Math.min(6, section.level))
|
|
10
|
+
const heading = '#'.repeat(depth)
|
|
11
|
+
const body = section.markdown || section.summary || ''
|
|
12
|
+
return `${heading} ${section.title}\n\n${body}`.trim()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function registerFetchRepoTool(mcp: McpServer, client: CodeWikiClient, config?: CodeWikiConfig): void {
|
|
16
|
+
mcp.tool(
|
|
17
|
+
'codewiki_fetch_repo',
|
|
18
|
+
'Fetch generated wiki content for a repository from codewiki.google',
|
|
19
|
+
FetchRepoInput.shape,
|
|
20
|
+
async (rawInput) => {
|
|
21
|
+
const parsed = FetchRepoInput.safeParse(rawInput)
|
|
22
|
+
if (!parsed.success) {
|
|
23
|
+
return {
|
|
24
|
+
content: [{ type: 'text', text: `Invalid arguments: ${parsed.error.message}` }],
|
|
25
|
+
isError: true,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const { repo, mode } = parsed.data
|
|
31
|
+
const resolved = await resolveRepoInput(repo, config)
|
|
32
|
+
const { data: result, meta } = await client.fetchRepository(resolved.repoUrl)
|
|
33
|
+
|
|
34
|
+
if (mode === 'pages') {
|
|
35
|
+
return {
|
|
36
|
+
content: [{
|
|
37
|
+
type: 'text',
|
|
38
|
+
text: JSON.stringify({
|
|
39
|
+
repo: result.repo,
|
|
40
|
+
commit: result.commit,
|
|
41
|
+
canonicalUrl: result.canonicalUrl,
|
|
42
|
+
pages: result.sections.map(section => ({
|
|
43
|
+
title: section.title,
|
|
44
|
+
level: section.level,
|
|
45
|
+
anchor: section.anchor,
|
|
46
|
+
markdown: section.markdown,
|
|
47
|
+
diagramCount: section.diagramCount,
|
|
48
|
+
})),
|
|
49
|
+
meta,
|
|
50
|
+
}, null, 2),
|
|
51
|
+
}],
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const aggregate = result.sections.map(toSectionMarkdown).join('\n\n')
|
|
56
|
+
const preface = [
|
|
57
|
+
`Repository: ${result.repo}`,
|
|
58
|
+
`Commit: ${result.commit ?? 'unknown'}`,
|
|
59
|
+
result.canonicalUrl ? `Canonical URL: ${result.canonicalUrl}` : null,
|
|
60
|
+
`Response: ${meta.totalBytes} bytes in ${meta.totalElapsedMs}ms`,
|
|
61
|
+
].filter(Boolean).join('\n')
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
content: [{
|
|
65
|
+
type: 'text',
|
|
66
|
+
text: `${preface}\n\n${aggregate}`.trim(),
|
|
67
|
+
}],
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return formatMcpError(err)
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
+
import type { CodeWikiClient } from '../lib/codewikiClient.js'
|
|
3
|
+
import { formatMcpError } from '../lib/errors.js'
|
|
4
|
+
import { SearchReposInput } from '../schemas.js'
|
|
5
|
+
|
|
6
|
+
export function registerSearchReposTool(mcp: McpServer, client: CodeWikiClient): void {
|
|
7
|
+
mcp.tool(
|
|
8
|
+
'codewiki_search_repos',
|
|
9
|
+
'Search repositories indexed by codewiki.google',
|
|
10
|
+
SearchReposInput.shape,
|
|
11
|
+
async (rawInput) => {
|
|
12
|
+
const parsed = SearchReposInput.safeParse(rawInput)
|
|
13
|
+
if (!parsed.success) {
|
|
14
|
+
return {
|
|
15
|
+
content: [{ type: 'text', text: `Invalid arguments: ${parsed.error.message}` }],
|
|
16
|
+
isError: true,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const { query, limit } = parsed.data
|
|
22
|
+
const { data: items, meta } = await client.searchRepositories(query, limit)
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
content: [{
|
|
26
|
+
type: 'text',
|
|
27
|
+
text: JSON.stringify({
|
|
28
|
+
query,
|
|
29
|
+
count: items.length,
|
|
30
|
+
items,
|
|
31
|
+
meta,
|
|
32
|
+
}, null, 2),
|
|
33
|
+
}],
|
|
34
|
+
}
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return formatMcpError(err)
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { extractRpcPayload, extractWrbFrames } from '../src/lib/batchexecute.js'
|
|
3
|
+
|
|
4
|
+
describe('batchexecute parser', () => {
|
|
5
|
+
it('extracts wrb.fr frame with XSSI prefix and length lines', () => {
|
|
6
|
+
const payload = [[['owner/repo', null, 3]]]
|
|
7
|
+
const payloadJson = JSON.stringify(payload)
|
|
8
|
+
const wrbLine = JSON.stringify([[
|
|
9
|
+
'wrb.fr',
|
|
10
|
+
'vyWDAf',
|
|
11
|
+
payloadJson,
|
|
12
|
+
null,
|
|
13
|
+
null,
|
|
14
|
+
null,
|
|
15
|
+
'generic',
|
|
16
|
+
]])
|
|
17
|
+
|
|
18
|
+
const text = `)]}'\n\n${wrbLine.length}\n${wrbLine}\n26\n[[\"e\",4,null,null,0]]\n`
|
|
19
|
+
|
|
20
|
+
const frames = extractWrbFrames(text)
|
|
21
|
+
expect(frames).toHaveLength(1)
|
|
22
|
+
expect(frames[0].rpcId).toBe('vyWDAf')
|
|
23
|
+
expect(frames[0].payload).toEqual(payload)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('extracts specific RPC payload when multiple frames exist', () => {
|
|
27
|
+
const firstPayload = ['one']
|
|
28
|
+
const secondPayload = ['two']
|
|
29
|
+
const line = JSON.stringify([
|
|
30
|
+
['wrb.fr', 'vyWDAf', JSON.stringify(firstPayload), null, null, null, 'generic'],
|
|
31
|
+
['wrb.fr', 'VSX6ub', JSON.stringify(secondPayload), null, null, null, 'generic'],
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
const text = `)]}'\n${line}`
|
|
35
|
+
|
|
36
|
+
expect(extractRpcPayload(text, 'VSX6ub')).toEqual(secondPayload)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('throws when no wrb.fr frames found', () => {
|
|
40
|
+
expect(() => extractWrbFrames(`)]}'\n42\n{}`)).toThrow('No wrb.fr frames found')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { CodeWikiClient } from '../src/lib/codewikiClient.js'
|
|
3
|
+
|
|
4
|
+
function makeRpcResponse(rpcId: string, payload: unknown): string {
|
|
5
|
+
const wrbLine = JSON.stringify([[
|
|
6
|
+
'wrb.fr',
|
|
7
|
+
rpcId,
|
|
8
|
+
JSON.stringify(payload),
|
|
9
|
+
null,
|
|
10
|
+
null,
|
|
11
|
+
null,
|
|
12
|
+
'generic',
|
|
13
|
+
]])
|
|
14
|
+
return `)]}'\n\n${wrbLine}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('CodeWikiClient', () => {
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.restoreAllMocks()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('searchRepositories parses vyWDAf result', async () => {
|
|
23
|
+
const payload = [[
|
|
24
|
+
[
|
|
25
|
+
'aiogram/aiogram',
|
|
26
|
+
null,
|
|
27
|
+
3,
|
|
28
|
+
[null, 'https://github.com/aiogram/aiogram'],
|
|
29
|
+
[123, 456],
|
|
30
|
+
['desc', 'https://img', 555, 'aiogram/aiogram'],
|
|
31
|
+
],
|
|
32
|
+
]]
|
|
33
|
+
|
|
34
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(makeRpcResponse('vyWDAf', payload), { status: 200 })))
|
|
35
|
+
|
|
36
|
+
const client = new CodeWikiClient()
|
|
37
|
+
const { data: result, meta } = await client.searchRepositories('aiogram', 5)
|
|
38
|
+
|
|
39
|
+
expect(result).toHaveLength(1)
|
|
40
|
+
expect(result[0].fullName).toBe('aiogram/aiogram')
|
|
41
|
+
expect(result[0].url).toBe('https://github.com/aiogram/aiogram')
|
|
42
|
+
expect(result[0].description).toBe('desc')
|
|
43
|
+
expect(meta.totalBytes).toBeGreaterThan(0)
|
|
44
|
+
expect(meta.totalElapsedMs).toBeGreaterThanOrEqual(0)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('fetchRepository parses VSX6ub result and sections', async () => {
|
|
48
|
+
const payload = [
|
|
49
|
+
[
|
|
50
|
+
['aiogram/aiogram', 'abc123'],
|
|
51
|
+
[
|
|
52
|
+
['Overview', 1, 'summary text', null, 'markdown fallback', 'markdown main', 1, [[1]], null, '#overview'],
|
|
53
|
+
['Details', 2, null, null, 'details markdown'],
|
|
54
|
+
],
|
|
55
|
+
null,
|
|
56
|
+
],
|
|
57
|
+
[[null, 'https://github.com/aiogram/aiogram'], true, 3],
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(makeRpcResponse('VSX6ub', payload), { status: 200 })))
|
|
61
|
+
|
|
62
|
+
const client = new CodeWikiClient()
|
|
63
|
+
const { data: result, meta } = await client.fetchRepository('aiogram/aiogram')
|
|
64
|
+
|
|
65
|
+
expect(result.repo).toBe('aiogram/aiogram')
|
|
66
|
+
expect(result.commit).toBe('abc123')
|
|
67
|
+
expect(result.canonicalUrl).toBe('https://github.com/aiogram/aiogram')
|
|
68
|
+
expect(result.sections).toHaveLength(2)
|
|
69
|
+
expect(result.sections[0].title).toBe('Overview')
|
|
70
|
+
expect(result.sections[0].markdown).toBe('markdown main')
|
|
71
|
+
expect(result.sections[0].anchor).toBe('#overview')
|
|
72
|
+
expect(result.sections[1].level).toBe(2)
|
|
73
|
+
expect(meta.totalBytes).toBeGreaterThan(0)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('askRepository parses EgIxfe answer', async () => {
|
|
77
|
+
const payload = ['Final answer text']
|
|
78
|
+
|
|
79
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(makeRpcResponse('EgIxfe', payload), { status: 200 })))
|
|
80
|
+
|
|
81
|
+
const client = new CodeWikiClient()
|
|
82
|
+
const { data: answer, meta } = await client.askRepository('https://github.com/aiogram/aiogram', 'what is this?')
|
|
83
|
+
|
|
84
|
+
expect(answer).toBe('Final answer text')
|
|
85
|
+
expect(meta.totalBytes).toBeGreaterThan(0)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('throws CodeWikiError on non-ok HTTP response', async () => {
|
|
89
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response('bad', { status: 500 })))
|
|
90
|
+
|
|
91
|
+
const client = new CodeWikiClient({ maxRetries: 0 })
|
|
92
|
+
await expect(client.searchRepositories('aiogram', 5)).rejects.toThrow('CodeWiki RPC vyWDAf failed with status 500')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('retries on server error', async () => {
|
|
96
|
+
const mockFetch = vi.fn()
|
|
97
|
+
.mockResolvedValueOnce(new Response('bad', { status: 500 }))
|
|
98
|
+
.mockResolvedValueOnce(new Response(makeRpcResponse('vyWDAf', [[]]), { status: 200 }))
|
|
99
|
+
|
|
100
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
101
|
+
|
|
102
|
+
const client = new CodeWikiClient({ maxRetries: 1, retryDelay: 1 })
|
|
103
|
+
const { data } = await client.searchRepositories('test', 1)
|
|
104
|
+
|
|
105
|
+
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
106
|
+
expect(data).toEqual([])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('does not retry on 4xx errors', async () => {
|
|
110
|
+
const mockFetch = vi.fn()
|
|
111
|
+
.mockResolvedValue(new Response('bad', { status: 400 }))
|
|
112
|
+
|
|
113
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
114
|
+
|
|
115
|
+
const client = new CodeWikiClient({ maxRetries: 3, retryDelay: 1 })
|
|
116
|
+
await expect(client.searchRepositories('test', 1)).rejects.toThrow('failed with status 400')
|
|
117
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('creates client from config', () => {
|
|
121
|
+
const client = CodeWikiClient.fromConfig({
|
|
122
|
+
baseUrl: 'https://custom.host',
|
|
123
|
+
requestTimeout: 5000,
|
|
124
|
+
maxRetries: 5,
|
|
125
|
+
retryDelay: 100,
|
|
126
|
+
})
|
|
127
|
+
expect(client).toBeInstanceOf(CodeWikiClient)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { loadConfig } from '../src/lib/config.js'
|
|
3
|
+
import { CodeWikiError, formatMcpError } from '../src/lib/errors.js'
|
|
4
|
+
|
|
5
|
+
describe('CodeWikiError', () => {
|
|
6
|
+
it('creates error with code and message', () => {
|
|
7
|
+
const err = new CodeWikiError('RPC_FAIL', 'request failed')
|
|
8
|
+
expect(err.name).toBe('CodeWikiError')
|
|
9
|
+
expect(err.code).toBe('RPC_FAIL')
|
|
10
|
+
expect(err.message).toBe('request failed')
|
|
11
|
+
expect(err.statusCode).toBeUndefined()
|
|
12
|
+
expect(err.rpcId).toBeUndefined()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('creates error with optional fields', () => {
|
|
16
|
+
const err = new CodeWikiError('TIMEOUT', 'timed out', {
|
|
17
|
+
statusCode: 504,
|
|
18
|
+
rpcId: 'vyWDAf',
|
|
19
|
+
})
|
|
20
|
+
expect(err.code).toBe('TIMEOUT')
|
|
21
|
+
expect(err.statusCode).toBe(504)
|
|
22
|
+
expect(err.rpcId).toBe('vyWDAf')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('preserves cause', () => {
|
|
26
|
+
const cause = new Error('original')
|
|
27
|
+
const err = new CodeWikiError('RPC_FAIL', 'wrapped', { cause })
|
|
28
|
+
expect((err as any).cause).toBe(cause)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('is an instance of Error', () => {
|
|
32
|
+
const err = new CodeWikiError('VALIDATION', 'bad input')
|
|
33
|
+
expect(err).toBeInstanceOf(Error)
|
|
34
|
+
expect(err).toBeInstanceOf(CodeWikiError)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('formatMcpError', () => {
|
|
39
|
+
it('formats CodeWikiError as structured envelope', () => {
|
|
40
|
+
const err = new CodeWikiError('RPC_FAIL', 'server error', {
|
|
41
|
+
statusCode: 500,
|
|
42
|
+
rpcId: 'VSX6ub',
|
|
43
|
+
})
|
|
44
|
+
const result = formatMcpError(err)
|
|
45
|
+
|
|
46
|
+
expect(result.isError).toBe(true)
|
|
47
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
48
|
+
expect(parsed.error.code).toBe('RPC_FAIL')
|
|
49
|
+
expect(parsed.error.message).toBe('server error')
|
|
50
|
+
expect(parsed.error.statusCode).toBe(500)
|
|
51
|
+
expect(parsed.error.rpcId).toBe('VSX6ub')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('formats plain Error as RPC_FAIL envelope', () => {
|
|
55
|
+
const err = new Error('something broke')
|
|
56
|
+
const result = formatMcpError(err)
|
|
57
|
+
|
|
58
|
+
expect(result.isError).toBe(true)
|
|
59
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
60
|
+
expect(parsed.error.code).toBe('RPC_FAIL')
|
|
61
|
+
expect(parsed.error.message).toBe('something broke')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('formats string error', () => {
|
|
65
|
+
const result = formatMcpError('raw string error')
|
|
66
|
+
|
|
67
|
+
expect(result.isError).toBe(true)
|
|
68
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
69
|
+
expect(parsed.error.code).toBe('RPC_FAIL')
|
|
70
|
+
expect(parsed.error.message).toBe('raw string error')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('omits optional fields when not present', () => {
|
|
74
|
+
const err = new CodeWikiError('VALIDATION', 'bad')
|
|
75
|
+
const result = formatMcpError(err)
|
|
76
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
77
|
+
expect(parsed.error.rpcId).toBeUndefined()
|
|
78
|
+
expect(parsed.error.statusCode).toBeUndefined()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('loadConfig', () => {
|
|
83
|
+
it('returns defaults when no env vars set', () => {
|
|
84
|
+
const config = loadConfig({})
|
|
85
|
+
expect(config.baseUrl).toBe('https://codewiki.google')
|
|
86
|
+
expect(config.requestTimeout).toBe(30_000)
|
|
87
|
+
expect(config.maxRetries).toBe(3)
|
|
88
|
+
expect(config.retryDelay).toBe(250)
|
|
89
|
+
expect(config.githubToken).toBeUndefined()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('reads env vars when set', () => {
|
|
93
|
+
const config = loadConfig({
|
|
94
|
+
CODEWIKI_BASE_URL: 'https://custom.host',
|
|
95
|
+
CODEWIKI_REQUEST_TIMEOUT: '5000',
|
|
96
|
+
CODEWIKI_MAX_RETRIES: '5',
|
|
97
|
+
CODEWIKI_RETRY_DELAY: '100',
|
|
98
|
+
GITHUB_TOKEN: 'ghp_test123',
|
|
99
|
+
})
|
|
100
|
+
expect(config.baseUrl).toBe('https://custom.host')
|
|
101
|
+
expect(config.requestTimeout).toBe(5000)
|
|
102
|
+
expect(config.maxRetries).toBe(5)
|
|
103
|
+
expect(config.retryDelay).toBe(100)
|
|
104
|
+
expect(config.githubToken).toBe('ghp_test123')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('falls back to defaults for invalid numbers', () => {
|
|
108
|
+
const config = loadConfig({
|
|
109
|
+
CODEWIKI_REQUEST_TIMEOUT: 'not-a-number',
|
|
110
|
+
CODEWIKI_MAX_RETRIES: '-1',
|
|
111
|
+
CODEWIKI_RETRY_DELAY: '0',
|
|
112
|
+
})
|
|
113
|
+
expect(config.requestTimeout).toBe(30_000)
|
|
114
|
+
expect(config.maxRetries).toBe(3)
|
|
115
|
+
expect(config.retryDelay).toBe(250)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('treats empty GITHUB_TOKEN as undefined', () => {
|
|
119
|
+
const config = loadConfig({ GITHUB_TOKEN: '' })
|
|
120
|
+
expect(config.githubToken).toBeUndefined()
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { extractKeyword } from '../src/lib/extractKeyword.js'
|
|
3
|
+
|
|
4
|
+
describe('extractKeyword', () => {
|
|
5
|
+
it('extracts noun from simple query', () => {
|
|
6
|
+
const result = extractKeyword('find the fastify framework')
|
|
7
|
+
expect(result).toBe('fastify')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('extracts proper noun', () => {
|
|
11
|
+
const result = extractKeyword('tell me about React')
|
|
12
|
+
expect(result).toBe('React')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('filters stop words like "repository"', () => {
|
|
16
|
+
const result = extractKeyword('show me the repository for express')
|
|
17
|
+
expect(result).toBe('express')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns null for all stop words', () => {
|
|
21
|
+
const result = extractKeyword('show me the')
|
|
22
|
+
expect(result).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('handles single word input', () => {
|
|
26
|
+
const result = extractKeyword('tensorflow')
|
|
27
|
+
expect(result).toBe('tensorflow')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('returns null for empty string', () => {
|
|
31
|
+
const result = extractKeyword('')
|
|
32
|
+
expect(result).toBeNull()
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { resolveRepoFromGitHub } from '../src/lib/resolveRepo.js'
|
|
3
|
+
import { resolveRepoInput } from '../src/lib/repo.js'
|
|
4
|
+
|
|
5
|
+
describe('resolveRepoFromGitHub', () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.restoreAllMocks()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('resolves keyword to full_name from GitHub API', async () => {
|
|
11
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(
|
|
12
|
+
JSON.stringify({ items: [{ full_name: 'fastify/fastify' }] }),
|
|
13
|
+
{ status: 200 },
|
|
14
|
+
)))
|
|
15
|
+
|
|
16
|
+
const result = await resolveRepoFromGitHub('fastify', { requestTimeout: 5000 })
|
|
17
|
+
expect(result).toBe('fastify/fastify')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('throws NLP_RESOLVE_FAIL when no items found', async () => {
|
|
21
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(
|
|
22
|
+
JSON.stringify({ items: [] }),
|
|
23
|
+
{ status: 200 },
|
|
24
|
+
)))
|
|
25
|
+
|
|
26
|
+
await expect(
|
|
27
|
+
resolveRepoFromGitHub('nonexistent-xyz-abc', { requestTimeout: 5000 }),
|
|
28
|
+
).rejects.toThrow('No GitHub repository found')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('throws NLP_RESOLVE_FAIL on non-ok response', async () => {
|
|
32
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response('rate limited', { status: 403 })))
|
|
33
|
+
|
|
34
|
+
await expect(
|
|
35
|
+
resolveRepoFromGitHub('test', { requestTimeout: 5000 }),
|
|
36
|
+
).rejects.toThrow('GitHub search failed with status 403')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('includes auth header when token provided', async () => {
|
|
40
|
+
const mockFetch = vi.fn(async () => new Response(
|
|
41
|
+
JSON.stringify({ items: [{ full_name: 'owner/repo' }] }),
|
|
42
|
+
{ status: 200 },
|
|
43
|
+
))
|
|
44
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
45
|
+
|
|
46
|
+
await resolveRepoFromGitHub('test', { requestTimeout: 5000, githubToken: 'ghp_test' })
|
|
47
|
+
|
|
48
|
+
const callArgs = mockFetch.mock.calls[0]
|
|
49
|
+
const headers = callArgs[1]?.headers as Record<string, string>
|
|
50
|
+
expect(headers['Authorization']).toBe('Bearer ghp_test')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('resolveRepoInput', () => {
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
vi.restoreAllMocks()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('resolves URL synchronously', async () => {
|
|
60
|
+
const result = await resolveRepoInput('https://github.com/fastify/fastify')
|
|
61
|
+
expect(result.repoPath).toBe('fastify/fastify')
|
|
62
|
+
expect(result.host).toBe('github.com')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('resolves owner/repo synchronously', async () => {
|
|
66
|
+
const result = await resolveRepoInput('fastify/fastify')
|
|
67
|
+
expect(result.repoPath).toBe('fastify/fastify')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('resolves natural language via NLP + GitHub', async () => {
|
|
71
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(
|
|
72
|
+
JSON.stringify({ items: [{ full_name: 'fastify/fastify' }] }),
|
|
73
|
+
{ status: 200 },
|
|
74
|
+
)))
|
|
75
|
+
|
|
76
|
+
const result = await resolveRepoInput('the fastify web framework')
|
|
77
|
+
expect(result.repoPath).toBe('fastify/fastify')
|
|
78
|
+
})
|
|
79
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"outDir": "dist",
|
|
11
|
+
"rootDir": "src",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"sourceMap": true,
|
|
15
|
+
"types": ["node", "vitest/globals"]
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*.ts"]
|
|
18
|
+
}
|