Files
hermes-web-ui/tests/server/studio-mcp-autoinject.test.ts
ekko 751802fbd4 [codex] fix desktop mcp shutdown (#1871)
* fix desktop mcp shutdown

* docs desktop mcp shutdown
2026-07-01 09:21:25 +08:00

271 lines
10 KiB
TypeScript

import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const updateConfigYamlForProfileMock = vi.fn()
const listProfileNamesFromDiskMock = vi.fn()
const configMock = vi.hoisted(() => ({
port: 8648,
appHome: '/Users/test/.hermes-web-ui',
}))
vi.mock('../../packages/server/src/services/config-helpers', () => ({
updateConfigYamlForProfile: updateConfigYamlForProfileMock,
}))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
listProfileNamesFromDisk: listProfileNamesFromDiskMock,
}))
vi.mock('../../packages/server/src/config', () => ({
config: configMock,
}))
vi.mock('../../packages/server/src/services/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
},
}))
describe('studio MCP autoinject', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
delete process.env.HERMES_DESKTOP
delete process.env.HERMES_AGENT_NODE
delete process.env.AUTH_TOKEN
delete process.env.HERMES_WEB_UI_DISABLE_MCP_AUTOINJECT
delete process.env.HERMES_WEB_UI_ALLOW_TRANSIENT_MCP_AUTOINJECT
configMock.port = 8648
configMock.appHome = '/Users/test/.hermes-web-ui'
listProfileNamesFromDiskMock.mockReturnValue(['default', 'work'])
updateConfigYamlForProfileMock.mockImplementation(async (_profile: string, updater: any) => {
const updated = await updater({})
return updated.result
})
})
it('injects bundled MCP server into every profile without relying on a global PATH shim', async () => {
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
const result = await injectBundledMcpServer()
expect(result.targets.map(target => target.profile)).toEqual(['default', 'work'])
expect(updateConfigYamlForProfileMock).toHaveBeenCalledTimes(2)
const injectedDefault = await updateConfigYamlForProfileMock.mock.calls[0][1]({})
expect(injectedDefault.data.mcp_servers['hermes-studio-api']).toEqual({
command: process.execPath,
args: [join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'api'],
env: {
HERMES_WEB_UI_URL: 'http://127.0.0.1:8648',
HERMES_WEB_UI_HOME: '/Users/test/.hermes-web-ui',
HERMES_WEBUI_STATE_DIR: '/Users/test/.hermes-web-ui',
HERMES_WEB_UI_PROFILE: 'default',
HERMES_MCP_SERVER_NAME: 'hermes-studio-api',
HERMES_MCP_TOOLSET: 'api',
HERMES_WEB_UI_MANAGED_MCP: '1',
},
enabled: true,
})
expect(injectedDefault.data.mcp_servers['hermes-studio-devices']).toMatchObject({
command: process.execPath,
args: [join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'devices'],
env: {
HERMES_MCP_SERVER_NAME: 'hermes-studio-devices',
HERMES_MCP_TOOLSET: 'devices',
},
enabled: true,
})
expect(injectedDefault.data.mcp_servers['hermes-studio-use']).toMatchObject({
command: process.execPath,
args: [join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'use'],
env: {
HERMES_MCP_SERVER_NAME: 'hermes-studio-use',
HERMES_MCP_TOOLSET: 'use',
},
enabled: true,
})
const injectedWork = await updateConfigYamlForProfileMock.mock.calls[1][1]({})
expect(injectedWork.data.mcp_servers['hermes-studio-api'].env.HERMES_WEB_UI_PROFILE).toBe('work')
expect(result.serverNames).toEqual(['hermes-studio-api', 'hermes-studio-devices', 'hermes-studio-use'])
expect(result.command).toBe(process.execPath)
})
it('skips autoinject for transient preview homes by default', async () => {
configMock.appHome = '/private/tmp/wui-preview-home'
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
const result = await injectBundledMcpServer()
expect(result.targets).toEqual([])
expect(updateConfigYamlForProfileMock).not.toHaveBeenCalled()
})
it('allows transient preview autoinject when explicitly requested', async () => {
configMock.appHome = '/private/tmp/wui-preview-home'
process.env.HERMES_WEB_UI_ALLOW_TRANSIENT_MCP_AUTOINJECT = '1'
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
await injectBundledMcpServer()
expect(updateConfigYamlForProfileMock).toHaveBeenCalledTimes(2)
})
it('respects a user-disabled managed MCP server entry', async () => {
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
await injectBundledMcpServer()
const updated = await updateConfigYamlForProfileMock.mock.calls[0][1]({
mcp_servers: {
'hermes-studio-api': {
command: process.execPath,
args: [join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'api'],
env: {
HERMES_WEB_UI_URL: 'http://127.0.0.1:8648',
HERMES_WEB_UI_HOME: '/Users/test/.hermes-web-ui',
HERMES_WEBUI_STATE_DIR: '/Users/test/.hermes-web-ui',
HERMES_WEB_UI_PROFILE: 'default',
HERMES_MCP_SERVER_NAME: 'hermes-studio-api',
HERMES_MCP_TOOLSET: 'api',
HERMES_WEB_UI_MANAGED_MCP: '1',
},
enabled: false,
},
},
})
expect(updated.write).toBe(false)
expect(updated.result).toMatchObject({
status: 'skipped',
reason: 'existing hermes-studio-api MCP server is disabled by user',
})
expect(updated.data.mcp_servers['hermes-studio-api'].enabled).toBe(false)
})
it('cleans a disabled legacy managed MCP server entry before injecting split servers', async () => {
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
await injectBundledMcpServer()
const updated = await updateConfigYamlForProfileMock.mock.calls[0][1]({
mcp_servers: {
'hermes-studio': {
command: 'hermes-web-ui-mcp',
env: {
HERMES_WEB_UI_MANAGED_MCP: '1',
},
enabled: false,
},
},
})
expect(updated.write).not.toBe(false)
expect(updated.result).toMatchObject({
status: 'updated',
})
expect(updated.data.mcp_servers['hermes-studio']).toBeUndefined()
expect(updated.data.mcp_servers['hermes-studio-api']).toBeDefined()
expect(updated.data.mcp_servers['hermes-studio-devices']).toBeDefined()
expect(updated.data.mcp_servers['hermes-studio-use']).toBeDefined()
})
it('updates old managed PATH-only MCP entries to the bundled node script', async () => {
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
await injectBundledMcpServer()
const updated = await updateConfigYamlForProfileMock.mock.calls[0][1]({
mcp_servers: {
'hermes-studio': {
command: 'hermes-web-ui-mcp',
env: {
HERMES_WEB_UI_URL: 'http://127.0.0.1:8648',
HERMES_WEB_UI_HOME: '/tmp/hermes-web-ui-home',
HERMES_WEBUI_STATE_DIR: '/tmp/hermes-web-ui-home',
HERMES_WEB_UI_PROFILE: 'default',
HERMES_WEB_UI_MANAGED_MCP: '1',
},
enabled: true,
},
'hermes-web-ui-mcp': {
command: 'hermes-web-ui-mcp',
env: {
HERMES_WEB_UI_MANAGED_MCP: '1',
},
enabled: true,
},
},
})
expect(updated.result.status).toBe('updated')
expect(updated.data.mcp_servers['hermes-studio']).toBeUndefined()
expect(updated.data.mcp_servers['hermes-web-ui-mcp']).toBeUndefined()
expect(updated.data.mcp_servers['hermes-studio-api'].command).toBe(process.execPath)
expect(updated.data.mcp_servers['hermes-studio-api'].args).toEqual([join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'api'])
expect(updated.data.mcp_servers['hermes-studio-devices'].args).toEqual([join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'devices'])
expect(updated.data.mcp_servers['hermes-studio-use'].args).toEqual([join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'use'])
})
it('uses the desktop runtime node for bundled MCP servers when available', async () => {
process.env.HERMES_DESKTOP = 'true'
process.env.HERMES_AGENT_NODE = '/runtime/node'
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
await injectBundledMcpServer()
const injected = await updateConfigYamlForProfileMock.mock.calls[0][1]({})
expect(injected.data.mcp_servers['hermes-studio-api'].command).toBe('/runtime/node')
expect(injected.data.mcp_servers['hermes-studio-api'].args).toEqual([
join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'api',
])
expect(injected.data.mcp_servers['hermes-studio-devices'].args).toEqual([
join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'devices',
])
expect(injected.data.mcp_servers['hermes-studio-use'].args).toEqual([
join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'use',
])
})
it('removes stale injected tokens from managed server config', async () => {
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
await injectBundledMcpServer()
const updated = await updateConfigYamlForProfileMock.mock.calls[0][1]({
mcp_servers: {
'hermes-studio-api': {
command: 'hermes-web-ui-mcp',
args: ['api'],
env: {
HERMES_WEB_UI_URL: 'http://127.0.0.1:8648',
HERMES_WEB_UI_HOME: '/tmp/hermes-web-ui-home',
HERMES_WEBUI_STATE_DIR: '/tmp/hermes-web-ui-home',
HERMES_WEB_UI_PROFILE: 'default',
HERMES_MCP_SERVER_NAME: 'hermes-studio-api',
HERMES_MCP_TOOLSET: 'api',
HERMES_WEB_UI_MANAGED_MCP: '1',
HERMES_WEB_UI_TOKEN: 'old-token',
},
enabled: true,
},
},
})
expect(updated.result.status).toBe('updated')
expect(updated.data.mcp_servers['hermes-studio-api'].env.HERMES_WEB_UI_TOKEN).toBeUndefined()
})
it('skips an unmanaged existing server entry', async () => {
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
await injectBundledMcpServer()
const updated = await updateConfigYamlForProfileMock.mock.calls[0][1]({
mcp_servers: {
'hermes-studio': { command: 'custom-command' },
},
})
expect(updated.write).toBe(false)
expect(updated.result.status).toBe('skipped')
})
})