Merge pull request 'feat: MCP registry client with multi-source search' (#1) from feat/mcp-registry-client into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -23,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.15.0",
|
"packageManager": "pnpm@9.15.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
||||||
"@typescript-eslint/parser": "^8.56.0",
|
"@typescript-eslint/parser": "^8.56.0",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
|
|||||||
173
pnpm-lock.yaml
generated
173
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^25.3.0
|
||||||
|
version: 25.3.0
|
||||||
'@typescript-eslint/eslint-plugin':
|
'@typescript-eslint/eslint-plugin':
|
||||||
specifier: ^8.56.0
|
specifier: ^8.56.0
|
||||||
version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)
|
version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)
|
||||||
@@ -16,7 +19,7 @@ importers:
|
|||||||
version: 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)
|
version: 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^4.0.18
|
specifier: ^4.0.18
|
||||||
version: 4.0.18(vitest@4.0.18(jiti@2.6.1)(tsx@4.21.0))
|
version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0))
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^10.0.1
|
specifier: ^10.0.1
|
||||||
version: 10.0.1(jiti@2.6.1)
|
version: 10.0.1(jiti@2.6.1)
|
||||||
@@ -34,7 +37,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.0.18
|
specifier: ^4.0.18
|
||||||
version: 4.0.18(jiti@2.6.1)(tsx@4.21.0)
|
version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)
|
||||||
|
|
||||||
src/cli:
|
src/cli:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -52,10 +55,13 @@ importers:
|
|||||||
version: 13.1.0
|
version: 13.1.0
|
||||||
inquirer:
|
inquirer:
|
||||||
specifier: ^12.0.0
|
specifier: ^12.0.0
|
||||||
version: 12.11.1
|
version: 12.11.1(@types/node@25.3.0)
|
||||||
js-yaml:
|
js-yaml:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.1
|
version: 4.1.1
|
||||||
|
zod:
|
||||||
|
specifier: ^3.24.0
|
||||||
|
version: 3.25.76
|
||||||
|
|
||||||
src/db:
|
src/db:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -701,6 +707,9 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/node@25.3.0':
|
||||||
|
resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.56.0':
|
'@typescript-eslint/eslint-plugin@8.56.0':
|
||||||
resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==}
|
resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -1795,6 +1804,9 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
undici-types@7.18.2:
|
||||||
|
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||||
|
|
||||||
unpipe@1.0.0:
|
unpipe@1.0.0:
|
||||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -2098,100 +2110,128 @@ snapshots:
|
|||||||
|
|
||||||
'@inquirer/ansi@1.0.2': {}
|
'@inquirer/ansi@1.0.2': {}
|
||||||
|
|
||||||
'@inquirer/checkbox@4.3.2':
|
'@inquirer/checkbox@4.3.2(@types/node@25.3.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/ansi': 1.0.2
|
'@inquirer/ansi': 1.0.2
|
||||||
'@inquirer/core': 10.3.2
|
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||||
'@inquirer/figures': 1.0.15
|
'@inquirer/figures': 1.0.15
|
||||||
'@inquirer/type': 3.0.10
|
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||||
yoctocolors-cjs: 2.1.3
|
yoctocolors-cjs: 2.1.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
|
|
||||||
'@inquirer/confirm@5.1.21':
|
'@inquirer/confirm@5.1.21(@types/node@25.3.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 10.3.2
|
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||||
'@inquirer/type': 3.0.10
|
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
|
|
||||||
'@inquirer/core@10.3.2':
|
'@inquirer/core@10.3.2(@types/node@25.3.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/ansi': 1.0.2
|
'@inquirer/ansi': 1.0.2
|
||||||
'@inquirer/figures': 1.0.15
|
'@inquirer/figures': 1.0.15
|
||||||
'@inquirer/type': 3.0.10
|
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||||
cli-width: 4.1.0
|
cli-width: 4.1.0
|
||||||
mute-stream: 2.0.0
|
mute-stream: 2.0.0
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
wrap-ansi: 6.2.0
|
wrap-ansi: 6.2.0
|
||||||
yoctocolors-cjs: 2.1.3
|
yoctocolors-cjs: 2.1.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
|
|
||||||
'@inquirer/editor@4.2.23':
|
'@inquirer/editor@4.2.23(@types/node@25.3.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 10.3.2
|
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||||
'@inquirer/external-editor': 1.0.3
|
'@inquirer/external-editor': 1.0.3(@types/node@25.3.0)
|
||||||
'@inquirer/type': 3.0.10
|
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
|
|
||||||
'@inquirer/expand@4.0.23':
|
'@inquirer/expand@4.0.23(@types/node@25.3.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 10.3.2
|
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||||
'@inquirer/type': 3.0.10
|
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||||
yoctocolors-cjs: 2.1.3
|
yoctocolors-cjs: 2.1.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
|
|
||||||
'@inquirer/external-editor@1.0.3':
|
'@inquirer/external-editor@1.0.3(@types/node@25.3.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
chardet: 2.1.1
|
chardet: 2.1.1
|
||||||
iconv-lite: 0.7.2
|
iconv-lite: 0.7.2
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
|
|
||||||
'@inquirer/figures@1.0.15': {}
|
'@inquirer/figures@1.0.15': {}
|
||||||
|
|
||||||
'@inquirer/input@4.3.1':
|
'@inquirer/input@4.3.1(@types/node@25.3.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 10.3.2
|
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||||
'@inquirer/type': 3.0.10
|
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
|
|
||||||
'@inquirer/number@3.0.23':
|
'@inquirer/number@3.0.23(@types/node@25.3.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 10.3.2
|
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||||
'@inquirer/type': 3.0.10
|
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
|
|
||||||
'@inquirer/password@4.0.23':
|
'@inquirer/password@4.0.23(@types/node@25.3.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/ansi': 1.0.2
|
'@inquirer/ansi': 1.0.2
|
||||||
'@inquirer/core': 10.3.2
|
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||||
'@inquirer/type': 3.0.10
|
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
|
|
||||||
'@inquirer/prompts@7.10.1':
|
'@inquirer/prompts@7.10.1(@types/node@25.3.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/checkbox': 4.3.2
|
'@inquirer/checkbox': 4.3.2(@types/node@25.3.0)
|
||||||
'@inquirer/confirm': 5.1.21
|
'@inquirer/confirm': 5.1.21(@types/node@25.3.0)
|
||||||
'@inquirer/editor': 4.2.23
|
'@inquirer/editor': 4.2.23(@types/node@25.3.0)
|
||||||
'@inquirer/expand': 4.0.23
|
'@inquirer/expand': 4.0.23(@types/node@25.3.0)
|
||||||
'@inquirer/input': 4.3.1
|
'@inquirer/input': 4.3.1(@types/node@25.3.0)
|
||||||
'@inquirer/number': 3.0.23
|
'@inquirer/number': 3.0.23(@types/node@25.3.0)
|
||||||
'@inquirer/password': 4.0.23
|
'@inquirer/password': 4.0.23(@types/node@25.3.0)
|
||||||
'@inquirer/rawlist': 4.1.11
|
'@inquirer/rawlist': 4.1.11(@types/node@25.3.0)
|
||||||
'@inquirer/search': 3.2.2
|
'@inquirer/search': 3.2.2(@types/node@25.3.0)
|
||||||
'@inquirer/select': 4.4.2
|
'@inquirer/select': 4.4.2(@types/node@25.3.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
|
|
||||||
'@inquirer/rawlist@4.1.11':
|
'@inquirer/rawlist@4.1.11(@types/node@25.3.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 10.3.2
|
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||||
'@inquirer/type': 3.0.10
|
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||||
yoctocolors-cjs: 2.1.3
|
yoctocolors-cjs: 2.1.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
|
|
||||||
'@inquirer/search@3.2.2':
|
'@inquirer/search@3.2.2(@types/node@25.3.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 10.3.2
|
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||||
'@inquirer/figures': 1.0.15
|
'@inquirer/figures': 1.0.15
|
||||||
'@inquirer/type': 3.0.10
|
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||||
yoctocolors-cjs: 2.1.3
|
yoctocolors-cjs: 2.1.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
|
|
||||||
'@inquirer/select@4.4.2':
|
'@inquirer/select@4.4.2(@types/node@25.3.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/ansi': 1.0.2
|
'@inquirer/ansi': 1.0.2
|
||||||
'@inquirer/core': 10.3.2
|
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||||
'@inquirer/figures': 1.0.15
|
'@inquirer/figures': 1.0.15
|
||||||
'@inquirer/type': 3.0.10
|
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||||
yoctocolors-cjs: 2.1.3
|
yoctocolors-cjs: 2.1.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
|
|
||||||
'@inquirer/type@3.0.10': {}
|
'@inquirer/type@3.0.10(@types/node@25.3.0)':
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
|
|
||||||
'@jridgewell/resolve-uri@3.1.2': {}
|
'@jridgewell/resolve-uri@3.1.2': {}
|
||||||
|
|
||||||
@@ -2353,6 +2393,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/node@25.3.0':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 7.18.2
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@@ -2444,7 +2488,7 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.56.0
|
'@typescript-eslint/types': 8.56.0
|
||||||
eslint-visitor-keys: 5.0.1
|
eslint-visitor-keys: 5.0.1
|
||||||
|
|
||||||
'@vitest/coverage-v8@4.0.18(vitest@4.0.18(jiti@2.6.1)(tsx@4.21.0))':
|
'@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@bcoe/v8-coverage': 1.0.2
|
'@bcoe/v8-coverage': 1.0.2
|
||||||
'@vitest/utils': 4.0.18
|
'@vitest/utils': 4.0.18
|
||||||
@@ -2456,7 +2500,7 @@ snapshots:
|
|||||||
obug: 2.1.1
|
obug: 2.1.1
|
||||||
std-env: 3.10.0
|
std-env: 3.10.0
|
||||||
tinyrainbow: 3.0.3
|
tinyrainbow: 3.0.3
|
||||||
vitest: 4.0.18(jiti@2.6.1)(tsx@4.21.0)
|
vitest: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)
|
||||||
|
|
||||||
'@vitest/expect@4.0.18':
|
'@vitest/expect@4.0.18':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2467,13 +2511,13 @@ snapshots:
|
|||||||
chai: 6.2.2
|
chai: 6.2.2
|
||||||
tinyrainbow: 3.0.3
|
tinyrainbow: 3.0.3
|
||||||
|
|
||||||
'@vitest/mocker@4.0.18(vite@7.3.1(jiti@2.6.1)(tsx@4.21.0))':
|
'@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 4.0.18
|
'@vitest/spy': 4.0.18
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 7.3.1(jiti@2.6.1)(tsx@4.21.0)
|
vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)
|
||||||
|
|
||||||
'@vitest/pretty-format@4.0.18':
|
'@vitest/pretty-format@4.0.18':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3033,15 +3077,17 @@ snapshots:
|
|||||||
|
|
||||||
inherits@2.0.4: {}
|
inherits@2.0.4: {}
|
||||||
|
|
||||||
inquirer@12.11.1:
|
inquirer@12.11.1(@types/node@25.3.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/ansi': 1.0.2
|
'@inquirer/ansi': 1.0.2
|
||||||
'@inquirer/core': 10.3.2
|
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||||
'@inquirer/prompts': 7.10.1
|
'@inquirer/prompts': 7.10.1(@types/node@25.3.0)
|
||||||
'@inquirer/type': 3.0.10
|
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||||
mute-stream: 2.0.0
|
mute-stream: 2.0.0
|
||||||
run-async: 4.0.6
|
run-async: 4.0.6
|
||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
|
|
||||||
ip-address@10.0.1: {}
|
ip-address@10.0.1: {}
|
||||||
|
|
||||||
@@ -3532,6 +3578,8 @@ snapshots:
|
|||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
undici-types@7.18.2: {}
|
||||||
|
|
||||||
unpipe@1.0.0: {}
|
unpipe@1.0.0: {}
|
||||||
|
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
@@ -3540,7 +3588,7 @@ snapshots:
|
|||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
vite@7.3.1(jiti@2.6.1)(tsx@4.21.0):
|
vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.27.3
|
esbuild: 0.27.3
|
||||||
fdir: 6.5.0(picomatch@4.0.3)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
@@ -3549,14 +3597,15 @@ snapshots:
|
|||||||
rollup: 4.58.0
|
rollup: 4.58.0
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
tsx: 4.21.0
|
tsx: 4.21.0
|
||||||
|
|
||||||
vitest@4.0.18(jiti@2.6.1)(tsx@4.21.0):
|
vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 4.0.18
|
'@vitest/expect': 4.0.18
|
||||||
'@vitest/mocker': 4.0.18(vite@7.3.1(jiti@2.6.1)(tsx@4.21.0))
|
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0))
|
||||||
'@vitest/pretty-format': 4.0.18
|
'@vitest/pretty-format': 4.0.18
|
||||||
'@vitest/runner': 4.0.18
|
'@vitest/runner': 4.0.18
|
||||||
'@vitest/snapshot': 4.0.18
|
'@vitest/snapshot': 4.0.18
|
||||||
@@ -3573,8 +3622,10 @@ snapshots:
|
|||||||
tinyexec: 1.0.2
|
tinyexec: 1.0.2
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
tinyrainbow: 3.0.3
|
tinyrainbow: 3.0.3
|
||||||
vite: 7.3.1(jiti@2.6.1)(tsx@4.21.0)
|
vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- jiti
|
- jiti
|
||||||
- less
|
- less
|
||||||
|
|||||||
@@ -16,11 +16,12 @@
|
|||||||
"test:run": "vitest run"
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^13.0.0",
|
"@mcpctl/db": "workspace:*",
|
||||||
|
"@mcpctl/shared": "workspace:*",
|
||||||
"chalk": "^5.4.0",
|
"chalk": "^5.4.0",
|
||||||
|
"commander": "^13.0.0",
|
||||||
"inquirer": "^12.0.0",
|
"inquirer": "^12.0.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"@mcpctl/shared": "workspace:*",
|
"zod": "^3.24.0"
|
||||||
"@mcpctl/db": "workspace:*"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
105
src/cli/src/registry/client.ts
Normal file
105
src/cli/src/registry/client.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { RegistryServer, SearchOptions, RegistryClientConfig, RegistryName } from './types.js';
|
||||||
|
import { RegistrySource } from './base.js';
|
||||||
|
import { OfficialRegistrySource } from './sources/official.js';
|
||||||
|
import { GlamaRegistrySource } from './sources/glama.js';
|
||||||
|
import { SmitheryRegistrySource } from './sources/smithery.js';
|
||||||
|
import { RegistryCache } from './cache.js';
|
||||||
|
import { deduplicateResults } from './dedup.js';
|
||||||
|
import { rankResults } from './ranking.js';
|
||||||
|
|
||||||
|
export class RegistryClient {
|
||||||
|
private sources: Map<RegistryName, RegistrySource>;
|
||||||
|
private cache: RegistryCache;
|
||||||
|
private enabledRegistries: RegistryName[];
|
||||||
|
private metrics = {
|
||||||
|
queryLatencies: new Map<string, number[]>(),
|
||||||
|
errorCounts: new Map<string, number>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(config: RegistryClientConfig = {}) {
|
||||||
|
this.enabledRegistries = config.registries ?? ['official', 'glama', 'smithery'];
|
||||||
|
this.cache = new RegistryCache(config.cacheTTLMs);
|
||||||
|
|
||||||
|
this.sources = new Map<RegistryName, RegistrySource>([
|
||||||
|
['official', new OfficialRegistrySource()],
|
||||||
|
['glama', new GlamaRegistrySource()],
|
||||||
|
['smithery', new SmitheryRegistrySource()],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(options: SearchOptions): Promise<RegistryServer[]> {
|
||||||
|
// Check cache
|
||||||
|
const cached = this.cache.get(options.query, options);
|
||||||
|
if (cached !== null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registries = options.registries ?? this.enabledRegistries;
|
||||||
|
const limit = options.limit ?? 20;
|
||||||
|
|
||||||
|
// Query all enabled registries in parallel
|
||||||
|
const promises = registries
|
||||||
|
.map((name) => this.sources.get(name))
|
||||||
|
.filter((source): source is RegistrySource => source !== undefined)
|
||||||
|
.map(async (source) => {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const results = await source.search(options.query, limit);
|
||||||
|
this.recordLatency(source.name, Date.now() - start);
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
this.recordError(source.name);
|
||||||
|
// Graceful degradation: log and continue
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const settled = await Promise.all(promises);
|
||||||
|
let combined = settled.flat();
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if (options.verified === true) {
|
||||||
|
combined = combined.filter((s) => s.verified);
|
||||||
|
}
|
||||||
|
if (options.transport !== undefined) {
|
||||||
|
combined = combined.filter((s) => s.transport === options.transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate, rank, and limit
|
||||||
|
const deduped = deduplicateResults(combined);
|
||||||
|
const ranked = rankResults(deduped, options.query);
|
||||||
|
const results = ranked.slice(0, limit);
|
||||||
|
|
||||||
|
// Cache results
|
||||||
|
this.cache.set(options.query, options, results);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCacheMetrics(): { hits: number; misses: number; ratio: number } {
|
||||||
|
return this.cache.getHitRatio();
|
||||||
|
}
|
||||||
|
|
||||||
|
getQueryLatencies(): Map<string, number[]> {
|
||||||
|
return new Map(this.metrics.queryLatencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
getErrorCounts(): Map<string, number> {
|
||||||
|
return new Map(this.metrics.errorCounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordLatency(source: string, ms: number): void {
|
||||||
|
const existing = this.metrics.queryLatencies.get(source) ?? [];
|
||||||
|
existing.push(ms);
|
||||||
|
this.metrics.queryLatencies.set(source, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordError(source: string): void {
|
||||||
|
const count = this.metrics.errorCounts.get(source) ?? 0;
|
||||||
|
this.metrics.errorCounts.set(source, count + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/cli/src/registry/index.ts
Normal file
17
src/cli/src/registry/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export { RegistryClient } from './client.js';
|
||||||
|
export { RegistryCache } from './cache.js';
|
||||||
|
export { RegistrySource } from './base.js';
|
||||||
|
export { deduplicateResults } from './dedup.js';
|
||||||
|
export { rankResults } from './ranking.js';
|
||||||
|
export { withRetry } from './retry.js';
|
||||||
|
export { OfficialRegistrySource } from './sources/official.js';
|
||||||
|
export { GlamaRegistrySource } from './sources/glama.js';
|
||||||
|
export { SmitheryRegistrySource } from './sources/smithery.js';
|
||||||
|
export type {
|
||||||
|
RegistryServer,
|
||||||
|
SearchOptions,
|
||||||
|
RegistryClientConfig,
|
||||||
|
RegistryName,
|
||||||
|
EnvVar,
|
||||||
|
} from './types.js';
|
||||||
|
export { sanitizeString } from './types.js';
|
||||||
63
src/cli/src/registry/ranking.ts
Normal file
63
src/cli/src/registry/ranking.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { RegistryServer } from './types.js';
|
||||||
|
|
||||||
|
const WEIGHT_RELEVANCE = 0.4;
|
||||||
|
const WEIGHT_POPULARITY = 0.3;
|
||||||
|
const WEIGHT_VERIFIED = 0.2;
|
||||||
|
const WEIGHT_RECENCY = 0.1;
|
||||||
|
|
||||||
|
function textRelevance(server: RegistryServer, query: string): number {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const name = server.name.toLowerCase();
|
||||||
|
const desc = server.description.toLowerCase();
|
||||||
|
|
||||||
|
// Exact name match
|
||||||
|
if (name === q) return 1.0;
|
||||||
|
// Name starts with query
|
||||||
|
if (name.startsWith(q)) return 0.9;
|
||||||
|
// Name contains query
|
||||||
|
if (name.includes(q)) return 0.7;
|
||||||
|
// Description contains query
|
||||||
|
if (desc.includes(q)) return 0.4;
|
||||||
|
|
||||||
|
// Word-level matching
|
||||||
|
const queryWords = q.split(/\s+/);
|
||||||
|
const matchCount = queryWords.filter(
|
||||||
|
(w) => name.includes(w) || desc.includes(w),
|
||||||
|
).length;
|
||||||
|
return queryWords.length > 0 ? (matchCount / queryWords.length) * 0.3 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function popularityScore(server: RegistryServer): number {
|
||||||
|
// Normalize to 0-1 range; use log scale since popularity can vary hugely
|
||||||
|
if (server.popularityScore <= 0) return 0;
|
||||||
|
// Log scale: log10(1) = 0, log10(10000) ≈ 4 → normalize to 0-1 with cap at 100k
|
||||||
|
return Math.min(Math.log10(server.popularityScore + 1) / 5, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifiedScore(server: RegistryServer): number {
|
||||||
|
return server.verified ? 1.0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recencyScore(server: RegistryServer): number {
|
||||||
|
if (server.lastUpdated === undefined) return 0.5; // Unknown = middle score
|
||||||
|
const ageMs = Date.now() - server.lastUpdated.getTime();
|
||||||
|
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
||||||
|
// Less than 30 days = 1.0, decays to 0 at 365 days
|
||||||
|
return Math.max(0, 1 - ageDays / 365);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeScore(server: RegistryServer, query: string): number {
|
||||||
|
return (
|
||||||
|
WEIGHT_RELEVANCE * textRelevance(server, query) +
|
||||||
|
WEIGHT_POPULARITY * popularityScore(server) +
|
||||||
|
WEIGHT_VERIFIED * verifiedScore(server) +
|
||||||
|
WEIGHT_RECENCY * recencyScore(server)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rankResults(
|
||||||
|
results: RegistryServer[],
|
||||||
|
query: string,
|
||||||
|
): RegistryServer[] {
|
||||||
|
return [...results].sort((a, b) => computeScore(b, query) - computeScore(a, query));
|
||||||
|
}
|
||||||
16
src/cli/src/registry/retry.ts
Normal file
16
src/cli/src/registry/retry.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export async function withRetry<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
maxRetries = 3,
|
||||||
|
baseDelay = 1000,
|
||||||
|
): Promise<T> {
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === maxRetries - 1) throw error;
|
||||||
|
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
|
||||||
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Unreachable');
|
||||||
|
}
|
||||||
92
src/cli/src/registry/sources/glama.ts
Normal file
92
src/cli/src/registry/sources/glama.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { RegistrySource } from '../base.js';
|
||||||
|
import {
|
||||||
|
GlamaRegistryResponseSchema,
|
||||||
|
sanitizeString,
|
||||||
|
type GlamaServerEntry,
|
||||||
|
type RegistryServer,
|
||||||
|
} from '../types.js';
|
||||||
|
import { withRetry } from '../retry.js';
|
||||||
|
|
||||||
|
const BASE_URL = 'https://glama.ai/api/mcp/v1/servers';
|
||||||
|
|
||||||
|
export class GlamaRegistrySource extends RegistrySource {
|
||||||
|
readonly name = 'glama' as const;
|
||||||
|
|
||||||
|
async search(query: string, limit: number): Promise<RegistryServer[]> {
|
||||||
|
const results: RegistryServer[] = [];
|
||||||
|
let cursor: string | null | undefined;
|
||||||
|
|
||||||
|
while (results.length < limit) {
|
||||||
|
const url = new URL(BASE_URL);
|
||||||
|
url.searchParams.set('query', query);
|
||||||
|
if (cursor !== undefined && cursor !== null) {
|
||||||
|
url.searchParams.set('after', cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await withRetry(() => fetch(url.toString()));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Glama registry returned ${String(response.status)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw: unknown = await response.json();
|
||||||
|
const parsed = GlamaRegistryResponseSchema.parse(raw);
|
||||||
|
|
||||||
|
for (const entry of parsed.servers) {
|
||||||
|
results.push(this.normalizeResult(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.pageInfo.hasNextPage || parsed.servers.length === 0) break;
|
||||||
|
cursor = parsed.pageInfo.endCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected normalizeResult(raw: unknown): RegistryServer {
|
||||||
|
const entry = raw as GlamaServerEntry;
|
||||||
|
|
||||||
|
// Extract env vars from JSON Schema
|
||||||
|
const props = entry.environmentVariablesJsonSchema?.properties ?? {};
|
||||||
|
const envTemplate = Object.entries(props).map(([name, schemaProp]) => {
|
||||||
|
const envVar: import('../types.js').EnvVar = {
|
||||||
|
name,
|
||||||
|
description: sanitizeString(schemaProp.description ?? ''),
|
||||||
|
isSecret: name.toLowerCase().includes('token') ||
|
||||||
|
name.toLowerCase().includes('secret') ||
|
||||||
|
name.toLowerCase().includes('password') ||
|
||||||
|
name.toLowerCase().includes('key'),
|
||||||
|
};
|
||||||
|
if (schemaProp.default !== undefined) {
|
||||||
|
envVar.defaultValue = schemaProp.default;
|
||||||
|
}
|
||||||
|
return envVar;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine transport from attributes
|
||||||
|
const attrs = entry.attributes;
|
||||||
|
let transport: RegistryServer['transport'] = 'stdio';
|
||||||
|
if (attrs.includes('hosting:remote-capable') || attrs.includes('hosting:hybrid')) {
|
||||||
|
transport = 'sse';
|
||||||
|
}
|
||||||
|
|
||||||
|
const packages: RegistryServer['packages'] = {};
|
||||||
|
if (entry.slug !== '') {
|
||||||
|
packages.npm = entry.slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: RegistryServer = {
|
||||||
|
name: sanitizeString(entry.name),
|
||||||
|
description: sanitizeString(entry.description),
|
||||||
|
packages,
|
||||||
|
envTemplate,
|
||||||
|
transport,
|
||||||
|
popularityScore: 0, // Glama has no popularity metrics in list
|
||||||
|
verified: attrs.includes('author:official'),
|
||||||
|
sourceRegistry: 'glama',
|
||||||
|
};
|
||||||
|
if (entry.repository?.url !== undefined) {
|
||||||
|
result.repositoryUrl = entry.repository.url;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/cli/src/registry/sources/official.ts
Normal file
106
src/cli/src/registry/sources/official.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { RegistrySource } from '../base.js';
|
||||||
|
import {
|
||||||
|
OfficialRegistryResponseSchema,
|
||||||
|
sanitizeString,
|
||||||
|
type OfficialServerEntry,
|
||||||
|
type RegistryServer,
|
||||||
|
} from '../types.js';
|
||||||
|
import { withRetry } from '../retry.js';
|
||||||
|
|
||||||
|
const BASE_URL = 'https://registry.modelcontextprotocol.io/v0/servers';
|
||||||
|
|
||||||
|
export class OfficialRegistrySource extends RegistrySource {
|
||||||
|
readonly name = 'official' as const;
|
||||||
|
|
||||||
|
async search(query: string, limit: number): Promise<RegistryServer[]> {
|
||||||
|
const results: RegistryServer[] = [];
|
||||||
|
let cursor: string | null | undefined;
|
||||||
|
|
||||||
|
while (results.length < limit) {
|
||||||
|
const url = new URL(BASE_URL);
|
||||||
|
url.searchParams.set('search', query);
|
||||||
|
url.searchParams.set('limit', String(Math.min(limit - results.length, 100)));
|
||||||
|
if (cursor !== undefined && cursor !== null) {
|
||||||
|
url.searchParams.set('cursor', cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await withRetry(() => fetch(url.toString()));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Official registry returned ${String(response.status)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw: unknown = await response.json();
|
||||||
|
const parsed = OfficialRegistryResponseSchema.parse(raw);
|
||||||
|
|
||||||
|
for (const entry of parsed.servers) {
|
||||||
|
results.push(this.normalizeResult(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = parsed.metadata?.nextCursor;
|
||||||
|
if (cursor === null || cursor === undefined || parsed.servers.length === 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected normalizeResult(raw: unknown): RegistryServer {
|
||||||
|
const entry = raw as OfficialServerEntry;
|
||||||
|
const server = entry.server;
|
||||||
|
|
||||||
|
// Extract env vars from packages
|
||||||
|
const envTemplate = server.packages.flatMap((pkg: { environmentVariables: Array<{ name: string; description?: string; isSecret?: boolean }> }) =>
|
||||||
|
pkg.environmentVariables.map((ev: { name: string; description?: string; isSecret?: boolean }) => ({
|
||||||
|
name: ev.name,
|
||||||
|
description: sanitizeString(ev.description ?? ''),
|
||||||
|
isSecret: ev.isSecret ?? false,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine transport from packages or remotes
|
||||||
|
let transport: RegistryServer['transport'] = 'stdio';
|
||||||
|
if (server.packages.length > 0) {
|
||||||
|
const pkgTransport = server.packages[0]?.transport?.type;
|
||||||
|
if (pkgTransport === 'stdio') transport = 'stdio';
|
||||||
|
}
|
||||||
|
if (server.remotes.length > 0) {
|
||||||
|
const remoteType = server.remotes[0]?.type;
|
||||||
|
if (remoteType === 'sse') transport = 'sse';
|
||||||
|
else if (remoteType === 'streamable-http') transport = 'streamable-http';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract npm package identifier
|
||||||
|
const npmPkg = server.packages.find((p: { registryType: string }) => p.registryType === 'npm');
|
||||||
|
const dockerPkg = server.packages.find((p: { registryType: string }) => p.registryType === 'oci');
|
||||||
|
|
||||||
|
// Extract dates from _meta
|
||||||
|
const meta = entry._meta as Record<string, Record<string, unknown>> | undefined;
|
||||||
|
const officialMeta = meta?.['io.modelcontextprotocol.registry/official'];
|
||||||
|
const updatedAt = officialMeta?.['updatedAt'];
|
||||||
|
|
||||||
|
const packages: RegistryServer['packages'] = {};
|
||||||
|
if (npmPkg !== undefined) {
|
||||||
|
packages.npm = npmPkg.identifier;
|
||||||
|
}
|
||||||
|
if (dockerPkg !== undefined) {
|
||||||
|
packages.docker = dockerPkg.identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: RegistryServer = {
|
||||||
|
name: sanitizeString(server.title ?? server.name),
|
||||||
|
description: sanitizeString(server.description),
|
||||||
|
packages,
|
||||||
|
envTemplate,
|
||||||
|
transport,
|
||||||
|
popularityScore: 0, // Official registry has no popularity data
|
||||||
|
verified: false, // Official registry has no verified badges
|
||||||
|
sourceRegistry: 'official',
|
||||||
|
};
|
||||||
|
if (server.repository?.url !== undefined) {
|
||||||
|
result.repositoryUrl = server.repository.url;
|
||||||
|
}
|
||||||
|
if (typeof updatedAt === 'string') {
|
||||||
|
result.lastUpdated = new Date(updatedAt);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/cli/src/registry/sources/smithery.ts
Normal file
62
src/cli/src/registry/sources/smithery.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { RegistrySource } from '../base.js';
|
||||||
|
import {
|
||||||
|
SmitheryRegistryResponseSchema,
|
||||||
|
sanitizeString,
|
||||||
|
type SmitheryServerEntry,
|
||||||
|
type RegistryServer,
|
||||||
|
} from '../types.js';
|
||||||
|
import { withRetry } from '../retry.js';
|
||||||
|
|
||||||
|
const BASE_URL = 'https://registry.smithery.ai/servers';
|
||||||
|
|
||||||
|
export class SmitheryRegistrySource extends RegistrySource {
|
||||||
|
readonly name = 'smithery' as const;
|
||||||
|
|
||||||
|
async search(query: string, limit: number): Promise<RegistryServer[]> {
|
||||||
|
const results: RegistryServer[] = [];
|
||||||
|
let page = 1;
|
||||||
|
|
||||||
|
while (results.length < limit) {
|
||||||
|
const url = new URL(BASE_URL);
|
||||||
|
url.searchParams.set('q', query);
|
||||||
|
url.searchParams.set('pageSize', String(Math.min(limit - results.length, 50)));
|
||||||
|
url.searchParams.set('page', String(page));
|
||||||
|
|
||||||
|
const response = await withRetry(() => fetch(url.toString()));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Smithery registry returned ${String(response.status)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw: unknown = await response.json();
|
||||||
|
const parsed = SmitheryRegistryResponseSchema.parse(raw);
|
||||||
|
|
||||||
|
for (const entry of parsed.servers) {
|
||||||
|
results.push(this.normalizeResult(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page >= parsed.pagination.totalPages || parsed.servers.length === 0) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected normalizeResult(raw: unknown): RegistryServer {
|
||||||
|
const entry = raw as SmitheryServerEntry;
|
||||||
|
|
||||||
|
const result: RegistryServer = {
|
||||||
|
name: sanitizeString(entry.displayName !== '' ? entry.displayName : entry.qualifiedName),
|
||||||
|
description: sanitizeString(entry.description),
|
||||||
|
packages: {},
|
||||||
|
envTemplate: [], // Smithery doesn't include env vars in list view
|
||||||
|
transport: entry.remote ? 'sse' : 'stdio',
|
||||||
|
popularityScore: entry.useCount,
|
||||||
|
verified: entry.verified,
|
||||||
|
sourceRegistry: 'smithery',
|
||||||
|
};
|
||||||
|
if (entry.createdAt !== undefined) {
|
||||||
|
result.lastUpdated = new Date(entry.createdAt);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,7 +173,7 @@ export type SmitheryServerEntry = z.infer<typeof SmitheryServerSchema>;
|
|||||||
|
|
||||||
// ── Security utilities ──
|
// ── Security utilities ──
|
||||||
|
|
||||||
const ANSI_ESCAPE_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F]|\x1b\[[0-9;]*[a-zA-Z]|\033\[[0-9;]*[a-zA-Z]/g;
|
const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*[a-zA-Z]|[\x00-\x08\x0B\x0C\x0E-\x1A\x1C-\x1F]|\x1b/g;
|
||||||
|
|
||||||
export function sanitizeString(text: string): string {
|
export function sanitizeString(text: string): string {
|
||||||
return text.replace(ANSI_ESCAPE_RE, '');
|
return text.replace(ANSI_ESCAPE_RE, '');
|
||||||
|
|||||||
90
src/cli/tests/registry/cache.test.ts
Normal file
90
src/cli/tests/registry/cache.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { RegistryCache } from '../../src/registry/cache.js';
|
||||||
|
import type { RegistryServer, SearchOptions } from '../../src/registry/types.js';
|
||||||
|
|
||||||
|
function makeServer(name: string): RegistryServer {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description: `${name} server`,
|
||||||
|
packages: {},
|
||||||
|
envTemplate: [],
|
||||||
|
transport: 'stdio',
|
||||||
|
popularityScore: 0,
|
||||||
|
verified: false,
|
||||||
|
sourceRegistry: 'official',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: SearchOptions = { query: 'test' };
|
||||||
|
|
||||||
|
describe('RegistryCache', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for cache miss', () => {
|
||||||
|
const cache = new RegistryCache();
|
||||||
|
expect(cache.get('unknown', defaultOptions)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns data for cache hit within TTL', () => {
|
||||||
|
const cache = new RegistryCache();
|
||||||
|
const data = [makeServer('test')];
|
||||||
|
cache.set('test', defaultOptions, data);
|
||||||
|
expect(cache.get('test', defaultOptions)).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null after TTL expires', () => {
|
||||||
|
const cache = new RegistryCache(1000); // 1 second TTL
|
||||||
|
cache.set('test', defaultOptions, [makeServer('test')]);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1001);
|
||||||
|
expect(cache.get('test', defaultOptions)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates deterministic cache keys', () => {
|
||||||
|
const cache = new RegistryCache();
|
||||||
|
const data = [makeServer('test')];
|
||||||
|
cache.set('query', { query: 'query', limit: 10 }, data);
|
||||||
|
expect(cache.get('query', { query: 'query', limit: 10 })).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates different keys for different queries', () => {
|
||||||
|
const cache = new RegistryCache();
|
||||||
|
cache.set('a', { query: 'a' }, [makeServer('a')]);
|
||||||
|
expect(cache.get('b', { query: 'b' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks hits and misses correctly', () => {
|
||||||
|
const cache = new RegistryCache();
|
||||||
|
cache.set('test', defaultOptions, [makeServer('test')]);
|
||||||
|
|
||||||
|
cache.get('test', defaultOptions); // hit
|
||||||
|
cache.get('test', defaultOptions); // hit
|
||||||
|
cache.get('miss', { query: 'miss' }); // miss
|
||||||
|
|
||||||
|
const ratio = cache.getHitRatio();
|
||||||
|
expect(ratio.hits).toBe(2);
|
||||||
|
expect(ratio.misses).toBe(1);
|
||||||
|
expect(ratio.ratio).toBeCloseTo(2 / 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 ratio when no accesses', () => {
|
||||||
|
const cache = new RegistryCache();
|
||||||
|
expect(cache.getHitRatio().ratio).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all entries and resets metrics', () => {
|
||||||
|
const cache = new RegistryCache();
|
||||||
|
cache.set('a', { query: 'a' }, [makeServer('a')]);
|
||||||
|
cache.get('a', { query: 'a' }); // hit
|
||||||
|
cache.clear();
|
||||||
|
|
||||||
|
expect(cache.get('a', { query: 'a' })).toBeNull();
|
||||||
|
expect(cache.size).toBe(0);
|
||||||
|
expect(cache.getHitRatio().hits).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
282
src/cli/tests/registry/client.test.ts
Normal file
282
src/cli/tests/registry/client.test.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { RegistryClient } from '../../src/registry/client.js';
|
||||||
|
import type { RegistryServer } from '../../src/registry/types.js';
|
||||||
|
|
||||||
|
function makeServer(name: string, source: 'official' | 'glama' | 'smithery'): RegistryServer {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description: `${name} description`,
|
||||||
|
packages: { npm: `@test/${name}` },
|
||||||
|
envTemplate: [],
|
||||||
|
transport: 'stdio',
|
||||||
|
popularityScore: 50,
|
||||||
|
verified: source === 'smithery',
|
||||||
|
sourceRegistry: source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock fetch globally
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
mockFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockRegistryResponse(source: string, servers: RegistryServer[]): void {
|
||||||
|
mockFetch.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('registry.modelcontextprotocol.io')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
servers: servers
|
||||||
|
.filter((s) => s.sourceRegistry === 'official')
|
||||||
|
.map((s) => ({
|
||||||
|
server: {
|
||||||
|
name: s.name,
|
||||||
|
description: s.description,
|
||||||
|
packages: s.packages.npm !== undefined ? [{
|
||||||
|
registryType: 'npm',
|
||||||
|
identifier: s.packages.npm,
|
||||||
|
transport: { type: 'stdio' },
|
||||||
|
environmentVariables: [],
|
||||||
|
}] : [],
|
||||||
|
remotes: [],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
metadata: { nextCursor: null, count: 1 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('glama.ai')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
servers: servers
|
||||||
|
.filter((s) => s.sourceRegistry === 'glama')
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.name,
|
||||||
|
name: s.name,
|
||||||
|
description: s.description,
|
||||||
|
attributes: [],
|
||||||
|
slug: s.packages.npm ?? '',
|
||||||
|
})),
|
||||||
|
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('registry.smithery.ai')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
servers: servers
|
||||||
|
.filter((s) => s.sourceRegistry === 'smithery')
|
||||||
|
.map((s) => ({
|
||||||
|
qualifiedName: s.name,
|
||||||
|
displayName: s.name,
|
||||||
|
description: s.description,
|
||||||
|
verified: s.verified,
|
||||||
|
useCount: s.popularityScore,
|
||||||
|
remote: false,
|
||||||
|
})),
|
||||||
|
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 1 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RegistryClient', () => {
|
||||||
|
it('queries all enabled registries', async () => {
|
||||||
|
const testServers = [
|
||||||
|
makeServer('slack-official', 'official'),
|
||||||
|
makeServer('slack-glama', 'glama'),
|
||||||
|
makeServer('slack-smithery', 'smithery'),
|
||||||
|
];
|
||||||
|
mockRegistryResponse('all', testServers);
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
const results = await client.search({ query: 'slack' });
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses cached results on second call', async () => {
|
||||||
|
mockRegistryResponse('all', [makeServer('slack', 'official')]);
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
await client.search({ query: 'slack' });
|
||||||
|
mockFetch.mockClear();
|
||||||
|
await client.search({ query: 'slack' });
|
||||||
|
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by registry when specified', async () => {
|
||||||
|
mockRegistryResponse('all', [makeServer('test', 'official')]);
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
await client.search({ query: 'test', registries: ['official'] });
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
const calledUrl = mockFetch.mock.calls[0]?.[0] as string;
|
||||||
|
expect(calledUrl).toContain('modelcontextprotocol.io');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles partial failures gracefully', async () => {
|
||||||
|
mockFetch.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('glama.ai')) {
|
||||||
|
return Promise.reject(new Error('Network error'));
|
||||||
|
}
|
||||||
|
if (url.includes('registry.smithery.ai')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
servers: [{
|
||||||
|
qualifiedName: 'slack',
|
||||||
|
displayName: 'Slack',
|
||||||
|
description: 'Slack',
|
||||||
|
verified: true,
|
||||||
|
useCount: 100,
|
||||||
|
remote: false,
|
||||||
|
}],
|
||||||
|
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 1 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
servers: [],
|
||||||
|
metadata: { nextCursor: null },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
const results = await client.search({ query: 'slack' });
|
||||||
|
|
||||||
|
// Should still return results from successful sources
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records error counts on failures', async () => {
|
||||||
|
mockFetch.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('glama.ai')) {
|
||||||
|
return Promise.reject(new Error('fail'));
|
||||||
|
}
|
||||||
|
// Return empty for others
|
||||||
|
if (url.includes('modelcontextprotocol')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ servers: [], metadata: { nextCursor: null } }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
servers: [],
|
||||||
|
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 0 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
await client.search({ query: 'test' });
|
||||||
|
|
||||||
|
const errors = client.getErrorCounts();
|
||||||
|
expect(errors.get('glama')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by verified when specified', async () => {
|
||||||
|
mockFetch.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('registry.smithery.ai')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
servers: [
|
||||||
|
{ qualifiedName: 'verified', displayName: 'Verified', description: '', verified: true, useCount: 100, remote: false },
|
||||||
|
{ qualifiedName: 'unverified', displayName: 'Unverified', description: '', verified: false, useCount: 50, remote: false },
|
||||||
|
],
|
||||||
|
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 2 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ servers: [], metadata: { nextCursor: null } }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock glama too
|
||||||
|
mockFetch.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('registry.smithery.ai')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
servers: [
|
||||||
|
{ qualifiedName: 'verified', displayName: 'Verified', description: '', verified: true, useCount: 100, remote: false },
|
||||||
|
{ qualifiedName: 'unverified', displayName: 'Unverified', description: '', verified: false, useCount: 50, remote: false },
|
||||||
|
],
|
||||||
|
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 2 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('glama.ai')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ servers: [], pageInfo: { hasNextPage: false, hasPreviousPage: false } }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ servers: [], metadata: { nextCursor: null } }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
const results = await client.search({ query: 'test', verified: true });
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
expect(r.verified).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects limit option', async () => {
|
||||||
|
mockRegistryResponse('all', [
|
||||||
|
makeServer('a', 'official'),
|
||||||
|
makeServer('b', 'glama'),
|
||||||
|
makeServer('c', 'smithery'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
const results = await client.search({ query: 'test', limit: 1 });
|
||||||
|
expect(results.length).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records latency metrics', async () => {
|
||||||
|
mockRegistryResponse('all', [makeServer('test', 'official')]);
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
await client.search({ query: 'test' });
|
||||||
|
|
||||||
|
const latencies = client.getQueryLatencies();
|
||||||
|
expect(latencies.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearCache empties cache', async () => {
|
||||||
|
mockRegistryResponse('all', [makeServer('test', 'official')]);
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
await client.search({ query: 'test' });
|
||||||
|
client.clearCache();
|
||||||
|
mockFetch.mockClear();
|
||||||
|
mockRegistryResponse('all', [makeServer('test', 'official')]);
|
||||||
|
await client.search({ query: 'test' });
|
||||||
|
|
||||||
|
// Should have fetched again after cache clear
|
||||||
|
expect(mockFetch).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
105
src/cli/tests/registry/dedup.test.ts
Normal file
105
src/cli/tests/registry/dedup.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { deduplicateResults } from '../../src/registry/dedup.js';
|
||||||
|
import type { RegistryServer } from '../../src/registry/types.js';
|
||||||
|
|
||||||
|
function makeServer(overrides: Partial<RegistryServer> = {}): RegistryServer {
|
||||||
|
return {
|
||||||
|
name: 'test-server',
|
||||||
|
description: 'A test server',
|
||||||
|
packages: {},
|
||||||
|
envTemplate: [],
|
||||||
|
transport: 'stdio',
|
||||||
|
popularityScore: 0,
|
||||||
|
verified: false,
|
||||||
|
sourceRegistry: 'official',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('deduplicateResults', () => {
|
||||||
|
it('keeps unique servers', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'server-a', packages: { npm: 'pkg-a' } }),
|
||||||
|
makeServer({ name: 'server-b', packages: { npm: 'pkg-b' } }),
|
||||||
|
];
|
||||||
|
expect(deduplicateResults(servers)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates by npm package name, keeps higher popularity', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'low', packages: { npm: '@test/slack' }, popularityScore: 10, sourceRegistry: 'official' }),
|
||||||
|
makeServer({ name: 'high', packages: { npm: '@test/slack' }, popularityScore: 100, sourceRegistry: 'smithery' }),
|
||||||
|
];
|
||||||
|
const result = deduplicateResults(servers);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.name).toBe('high');
|
||||||
|
expect(result[0]?.popularityScore).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates by GitHub URL with different formats', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'a', repositoryUrl: 'https://github.com/org/repo', popularityScore: 5 }),
|
||||||
|
makeServer({ name: 'b', repositoryUrl: 'git@github.com:org/repo.git', popularityScore: 50 }),
|
||||||
|
];
|
||||||
|
const result = deduplicateResults(servers);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.name).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges envTemplate from both sources', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({
|
||||||
|
name: 'a',
|
||||||
|
packages: { npm: 'pkg' },
|
||||||
|
envTemplate: [{ name: 'TOKEN', description: 'API token', isSecret: true }],
|
||||||
|
popularityScore: 10,
|
||||||
|
}),
|
||||||
|
makeServer({
|
||||||
|
name: 'b',
|
||||||
|
packages: { npm: 'pkg' },
|
||||||
|
envTemplate: [{ name: 'URL', description: 'Base URL', isSecret: false }],
|
||||||
|
popularityScore: 5,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const result = deduplicateResults(servers);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.envTemplate).toHaveLength(2);
|
||||||
|
expect(result[0]?.envTemplate.map((e) => e.name)).toContain('TOKEN');
|
||||||
|
expect(result[0]?.envTemplate.map((e) => e.name)).toContain('URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates envTemplate by var name', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({
|
||||||
|
packages: { npm: 'pkg' },
|
||||||
|
envTemplate: [{ name: 'TOKEN', description: 'from a', isSecret: true }],
|
||||||
|
popularityScore: 10,
|
||||||
|
}),
|
||||||
|
makeServer({
|
||||||
|
packages: { npm: 'pkg' },
|
||||||
|
envTemplate: [{ name: 'TOKEN', description: 'from b', isSecret: true }],
|
||||||
|
popularityScore: 5,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const result = deduplicateResults(servers);
|
||||||
|
expect(result[0]?.envTemplate).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges verified status (OR)', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ packages: { npm: 'pkg' }, verified: true, popularityScore: 10 }),
|
||||||
|
makeServer({ packages: { npm: 'pkg' }, verified: false, popularityScore: 5 }),
|
||||||
|
];
|
||||||
|
const result = deduplicateResults(servers);
|
||||||
|
expect(result[0]?.verified).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles servers with no npm or repo', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'a' }),
|
||||||
|
makeServer({ name: 'b' }),
|
||||||
|
];
|
||||||
|
// No matching key → no dedup
|
||||||
|
expect(deduplicateResults(servers)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
91
src/cli/tests/registry/ranking.test.ts
Normal file
91
src/cli/tests/registry/ranking.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { rankResults } from '../../src/registry/ranking.js';
|
||||||
|
import type { RegistryServer } from '../../src/registry/types.js';
|
||||||
|
|
||||||
|
function makeServer(overrides: Partial<RegistryServer> = {}): RegistryServer {
|
||||||
|
return {
|
||||||
|
name: 'test-server',
|
||||||
|
description: 'A test server',
|
||||||
|
packages: {},
|
||||||
|
envTemplate: [],
|
||||||
|
transport: 'stdio',
|
||||||
|
popularityScore: 0,
|
||||||
|
verified: false,
|
||||||
|
sourceRegistry: 'official',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('rankResults', () => {
|
||||||
|
it('puts exact name match first', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'slack-extended-tools' }),
|
||||||
|
makeServer({ name: 'slack' }),
|
||||||
|
makeServer({ name: 'my-slack-bot' }),
|
||||||
|
];
|
||||||
|
const ranked = rankResults(servers, 'slack');
|
||||||
|
expect(ranked[0]?.name).toBe('slack');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ranks verified servers higher than unverified', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'server-a', verified: false }),
|
||||||
|
makeServer({ name: 'server-b', verified: true }),
|
||||||
|
];
|
||||||
|
const ranked = rankResults(servers, 'server');
|
||||||
|
expect(ranked[0]?.name).toBe('server-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ranks popular servers higher', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'unpopular', popularityScore: 1 }),
|
||||||
|
makeServer({ name: 'popular', popularityScore: 10000 }),
|
||||||
|
];
|
||||||
|
const ranked = rankResults(servers, 'test');
|
||||||
|
expect(ranked[0]?.name).toBe('popular');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('considers recency', () => {
|
||||||
|
const recent = new Date();
|
||||||
|
const old = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'old-server', lastUpdated: old }),
|
||||||
|
makeServer({ name: 'new-server', lastUpdated: recent }),
|
||||||
|
];
|
||||||
|
const ranked = rankResults(servers, 'test');
|
||||||
|
expect(ranked[0]?.name).toBe('new-server');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing lastUpdated gracefully', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'no-date' }),
|
||||||
|
makeServer({ name: 'has-date', lastUpdated: new Date() }),
|
||||||
|
];
|
||||||
|
// Should not throw
|
||||||
|
const ranked = rankResults(servers, 'test');
|
||||||
|
expect(ranked).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces stable ordering for identical scores', () => {
|
||||||
|
const servers = Array.from({ length: 10 }, (_, i) =>
|
||||||
|
makeServer({ name: `server-${String(i)}` }),
|
||||||
|
);
|
||||||
|
const ranked1 = rankResults(servers, 'test');
|
||||||
|
const ranked2 = rankResults(servers, 'test');
|
||||||
|
expect(ranked1.map((s) => s.name)).toEqual(ranked2.map((s) => s.name));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty input', () => {
|
||||||
|
expect(rankResults([], 'test')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate original array', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'b' }),
|
||||||
|
makeServer({ name: 'a' }),
|
||||||
|
];
|
||||||
|
const original = [...servers];
|
||||||
|
rankResults(servers, 'test');
|
||||||
|
expect(servers.map((s) => s.name)).toEqual(original.map((s) => s.name));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user