Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions src/routes/api/mcp/-hub-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,28 +65,34 @@ describe('GET /api/mcp/hub-search — auth', () => {
})

describe('GET /api/mcp/hub-search — query parsing', () => {
it('passes q, source, limit to unifiedSearch', async () => {
it('passes q, source, limit, offset to unifiedSearch', async () => {
mockUnifiedSearch.mockResolvedValue({ results: [], source: 'mcp-get', total: 0 })
await callGet('http://localhost/api/mcp/hub-search?q=github&source=mcp-get&limit=5')
expect(mockUnifiedSearch).toHaveBeenCalledWith('github', 'mcp-get', 5)
await callGet('http://localhost/api/mcp/hub-search?q=github&source=mcp-get&limit=5&offset=10')
expect(mockUnifiedSearch).toHaveBeenCalledWith('github', 'mcp-get', 5, 10)
})

it('uses defaults when params absent', async () => {
mockUnifiedSearch.mockResolvedValue({ results: [], source: 'all', total: 0 })
await callGet('http://localhost/api/mcp/hub-search')
expect(mockUnifiedSearch).toHaveBeenCalledWith('', 'all', 20)
expect(mockUnifiedSearch).toHaveBeenCalledWith('', 'all', 20, 0)
})

it('clamps limit to 100', async () => {
it('clamps limit to 500', async () => {
mockUnifiedSearch.mockResolvedValue({ results: [], source: 'all', total: 0 })
await callGet('http://localhost/api/mcp/hub-search?limit=9999')
expect(mockUnifiedSearch).toHaveBeenCalledWith('', 'all', 100)
expect(mockUnifiedSearch).toHaveBeenCalledWith('', 'all', 500, 0)
})

it('defaults invalid source to all', async () => {
mockUnifiedSearch.mockResolvedValue({ results: [], source: 'all', total: 0 })
await callGet('http://localhost/api/mcp/hub-search?source=invalid')
expect(mockUnifiedSearch).toHaveBeenCalledWith('', 'all', 20)
expect(mockUnifiedSearch).toHaveBeenCalledWith('', 'all', 20, 0)
})

it('clamps negative offset to 0', async () => {
mockUnifiedSearch.mockResolvedValue({ results: [], source: 'all', total: 0 })
await callGet('http://localhost/api/mcp/hub-search?offset=-5')
expect(mockUnifiedSearch).toHaveBeenCalledWith('', 'all', 20, 0)
})
})

Expand Down
13 changes: 9 additions & 4 deletions src/routes/api/mcp/-presets.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'

import { presetsFilePath } from '../../../server/mcp-presets-store'

const VALID_SEED = {
version: 1,
Expand Down Expand Up @@ -95,7 +97,10 @@ describe('GET /api/mcp/presets', () => {

it('returns 200 with source=invalid + error fields when user file is malformed', async () => {
delete process.env.CLAUDE_PASSWORD
writeFileSync(join(homeDir, 'mcp-presets.json'), '{not valid json')
// The store resolves the user file via getStateDir() (HERMES_HOME/workspace)
const userFile = presetsFilePath()
mkdirSync(dirname(userFile), { recursive: true })
writeFileSync(userFile, '{not valid json')
const mod = await loadRoute()
const res = await mod.Route.server.handlers.GET({
request: new Request('http://localhost/api/mcp/presets'),
Expand All @@ -111,7 +116,7 @@ describe('GET /api/mcp/presets', () => {
expect(body.ok).toBe(false)
expect(body.source).toBe('invalid')
expect(body.error).toBeTruthy()
expect(body.errorPath).toBe(join(homeDir, 'mcp-presets.json'))
expect(body.errorPath).toBe(userFile)
expect((body.validationErrors ?? []).length).toBeGreaterThan(0)
})
})
3 changes: 2 additions & 1 deletion src/routes/api/mcp/hub-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
* Query params:
* q Free-text search query (default '')
* source 'all' | 'mcp-get' | 'local' (default 'all')
* limit Max results 1..100 (default 20)
* limit Max results 1..500 (default 20)
* offset Pagination offset >= 0 (default 0)
*
* Auth-gated via isAuthenticated.
* Rate-limited: 60 req/min per IP.
Expand Down
10 changes: 6 additions & 4 deletions src/server/mcp-hub-sources-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'

import {
__resetHubSourcesCacheForTests,
Expand All @@ -27,7 +28,8 @@ let homeDir: string
let originalHermesHome: string | undefined

function writeSourcesFile(payload: unknown): void {
const path = join(homeDir, 'mcp-hub-sources.json')
const path = hubSourcesFilePath()
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2))
}

Expand Down Expand Up @@ -86,12 +88,12 @@ describe('readHubSources', () => {

it('returns source=invalid for malformed JSON, preserves file', async () => {
writeSourcesFile('not-json{{{{')
const before = readFileSync(join(homeDir, 'mcp-hub-sources.json'), 'utf8')
const before = readFileSync(hubSourcesFilePath(), 'utf8')
const result = await readHubSources()
expect(result.source).toBe('invalid')
expect(result.error).toBeTruthy()
// File is preserved
const after = readFileSync(join(homeDir, 'mcp-hub-sources.json'), 'utf8')
const after = readFileSync(hubSourcesFilePath(), 'utf8')
expect(after).toBe(before)
})

Expand Down
30 changes: 18 additions & 12 deletions src/server/mcp-presets-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'

import {
__resetPresetsCacheForTests,
Expand Down Expand Up @@ -52,7 +52,8 @@ function writeSeed(payload: unknown): void {
}

function writeUserFile(payload: unknown): void {
const path = join(homeDir, 'mcp-presets.json')
const path = presetsFilePath()
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, typeof payload === 'string' ? payload : JSON.stringify(payload))
}

Expand Down Expand Up @@ -125,7 +126,7 @@ describe('readPresets', () => {
})

it('returns source=invalid for malformed user JSON and preserves the file unchanged', async () => {
const path = join(homeDir, 'mcp-presets.json')
const path = presetsFilePath()
const corrupt = '{this is not valid json'
writeUserFile(corrupt)
const result = await readPresets()
Expand Down Expand Up @@ -264,7 +265,10 @@ describe('readPresets', () => {
try {
const result = await readPresets()
expect(result.source).toBe('seed')
expect(existsSync(join(altHome, 'mcp-presets.json'))).toBe(true)
// presetsFilePath() resolves through getStateDir(), which honors
// HERMES_HOME at call time — the bootstrapped file must live there.
expect(presetsFilePath().startsWith(altHome)).toBe(true)
expect(existsSync(presetsFilePath())).toBe(true)
} finally {
rmSync(altHome, { recursive: true, force: true })
process.env.HERMES_HOME = homeDir
Expand All @@ -286,7 +290,8 @@ describe('readPresets', () => {
it('returns source=invalid when user file exists but is permission-denied (EACCES)', async () => {
// Skip on platforms where chmod doesn't restrict root
if (process.getuid?.() === 0) return
const path = join(homeDir, 'mcp-presets.json')
const path = presetsFilePath()
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, JSON.stringify(VALID_SEED))
chmodSync(path, 0o000)
__resetPresetsCacheForTests()
Expand All @@ -301,7 +306,8 @@ describe('readPresets', () => {
})

it('returns source=invalid when user file path is a dangling symlink', async () => {
const path = join(homeDir, 'mcp-presets.json')
const path = presetsFilePath()
mkdirSync(dirname(path), { recursive: true })
const nonexistent = join(homeDir, 'does-not-exist.json')
symlinkSync(nonexistent, path)
__resetPresetsCacheForTests()
Expand All @@ -313,9 +319,9 @@ describe('readPresets', () => {

// MED-5: cache detects same-size same-mtime edits via inode/ctime
it('cache invalidates on same-size same-mtime edit (detects via ctime/inode)', async () => {
const path = join(homeDir, 'mcp-presets.json')
const path = presetsFilePath()
const base = JSON.stringify(VALID_SEED)
writeFileSync(path, base)
writeUserFile(base)
const r1 = await readPresets()
expect(r1.presets.length).toBe(1)

Expand Down Expand Up @@ -347,10 +353,10 @@ describe('readPresets', () => {

// MED-6: category allowlist
it('rejects preset with unknown category', async () => {
writeFileSync(join(homeDir, 'mcp-presets.json'), JSON.stringify({
writeUserFile({
version: 1,
presets: [{ ...VALID_SEED.presets[0], category: 'RandomCategory' }],
}))
})
__resetPresetsCacheForTests()
const result = await readPresets()
expect(result.source).toBe('invalid')
Expand All @@ -362,10 +368,10 @@ describe('readPresets', () => {
it('defaults category to Custom when missing', async () => {
const presetWithoutCategory = { ...VALID_SEED.presets[0] } as Record<string, unknown>
delete presetWithoutCategory.category
writeFileSync(join(homeDir, 'mcp-presets.json'), JSON.stringify({
writeUserFile({
version: 1,
presets: [presetWithoutCategory],
}))
})
__resetPresetsCacheForTests()
const result = await readPresets()
expect(result.source).toBe('user-file')
Expand Down
10 changes: 7 additions & 3 deletions src/server/mcp-tools-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { getStateDir } from './workspace-state-dir'

// We re-import the module fresh for each test using dynamic import + cache busting,
// but since Vitest caches modules, we use the exported reset helper instead.

Expand Down Expand Up @@ -76,7 +78,7 @@ describe('write → read roundtrip', () => {
describe('corrupt file → empty cache', () => {
it('ignores corrupt JSON and starts with empty cache', async () => {
// Write corrupt file before module load
const cacheDir = join(tmpDir, 'cache')
const cacheDir = join(getStateDir(), 'cache')
mkdirSync(cacheDir, { recursive: true })
writeFileSync(join(cacheDir, 'mcp-tools.json'), '{ not valid json !!!', 'utf8')

Expand All @@ -94,7 +96,7 @@ describe('corrupt file → empty cache', () => {
})

it('ignores wrong schema (version != 1)', async () => {
const cacheDir = join(tmpDir, 'cache')
const cacheDir = join(getStateDir(), 'cache')
mkdirSync(cacheDir, { recursive: true })
writeFileSync(
join(cacheDir, 'mcp-tools.json'),
Expand Down Expand Up @@ -164,7 +166,9 @@ describe('HERMES_HOME override for path resolution', () => {
vi.resetModules()

const mod = await loadCache()
expect(mod.cacheFilePath()).toBe(join(customHome, 'cache', 'mcp-tools.json'))
// cacheFilePath() resolves via getStateDir() → $HERMES_HOME/workspace/cache
expect(mod.cacheFilePath()).toBe(join(getStateDir(), 'cache', 'mcp-tools.json'))
expect(mod.cacheFilePath().startsWith(customHome)).toBe(true)

mod.setProbe('server-x', {
status: 'failed',
Expand Down