Files
hermes-ui/tests/server/hermes-web-ui-mcp.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

851 lines
34 KiB
TypeScript

import { createServer } from 'http'
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import pkg from '../../package.json'
function writeRpc(child: ChildProcessWithoutNullStreams, id: number, method: string, params?: unknown) {
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`)
}
function waitForRpc(responses: Map<number, any>, id: number): Promise<any> {
return new Promise((resolve, reject) => {
const started = Date.now()
const timer = setInterval(() => {
if (responses.has(id)) {
clearInterval(timer)
resolve(responses.get(id))
return
}
if (Date.now() - started > 5000) {
clearInterval(timer)
reject(new Error(`Timed out waiting for MCP response ${id}`))
}
}, 10)
})
}
describe('hermes-web-ui MCP server', () => {
let child: ChildProcessWithoutNullStreams | null = null
const homes: string[] = []
afterEach(() => {
child?.kill()
child = null
for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
})
it('exposes a public Web UI API requester tool', async () => {
const home = mkdtempSync(join(tmpdir(), 'hermes-web-ui-mcp-'))
homes.push(home)
mkdirSync(join(home, 'profiles', 'research'), { recursive: true })
writeFileSync(join(home, 'profiles', 'research', '.model-run-token'), 'profile-token\n')
let chatRunHits = 0
const server = createServer((req, res) => {
if (req.url === '/api/openapi.json') {
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({
openapi: '3.0.3',
tags: [
{ name: 'Chat Run', description: 'Chat run operations' },
{ name: 'Testing', description: 'Test helper operations' },
],
paths: {
'/api/test-public-requester': {
post: {
tags: ['Testing'],
requestBody: {
required: false,
content: {
'application/json': {
schema: {
type: 'object',
},
},
},
},
},
},
'/api/chat-run/runs': {
post: {
tags: ['Chat Run'],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['input'],
properties: {
input: { type: 'string' },
},
},
},
},
},
},
},
},
authorization: req.headers.authorization || '',
profile: req.headers['x-hermes-profile'] || '',
}))
return
}
if (req.url?.startsWith('/api/test-public-requester')) {
let raw = ''
req.on('data', chunk => { raw += chunk })
req.on('end', () => {
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({
method: req.method,
url: req.url,
body: raw ? JSON.parse(raw) : null,
profile: req.headers['x-hermes-profile'],
authorization: req.headers.authorization,
}))
})
return
}
if (req.url === '/api/chat-run/runs') {
chatRunHits += 1
let raw = ''
req.on('data', chunk => { raw += chunk })
req.on('end', () => {
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({
ok: true,
body: raw ? JSON.parse(raw) : null,
}))
})
return
}
res.statusCode = 404
res.end('{}')
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('expected TCP server address')
const responses = new Map<number, any>()
child = spawn(process.execPath, ['bin/hermes-studio-mcp.mjs'], {
cwd: process.cwd(),
env: {
...process.env,
HERMES_WEB_UI_URL: `http://127.0.0.1:${address.port}`,
HERMES_WEB_UI_HOME: home,
HERMES_WEB_UI_PROFILE: 'research',
},
})
child.stdout.on('data', (chunk) => {
for (const line of String(chunk).trim().split('\n')) {
if (!line) continue
const message = JSON.parse(line)
responses.set(message.id, message)
}
})
writeRpc(child, 1, 'initialize', {})
writeRpc(child, 2, 'tools/list')
writeRpc(child, 3, 'tools/call', {
name: 'hermes_studio_api_request',
arguments: {
method: 'POST',
path: '/api/test-public-requester',
query: { q: 'hello' },
body: { ok: true },
},
})
writeRpc(child, 4, 'resources/list')
writeRpc(child, 5, 'tools/call', {
name: 'hermes_studio_api_request',
arguments: {
method: 'POST',
path: '/api/chat-run/runs',
body: {},
},
})
writeRpc(child, 7, 'tools/call', {
name: 'hermes_studio_api_openapi_get',
arguments: {
path: '/api/chat-run/runs',
method: 'POST',
},
})
writeRpc(child, 9, 'tools/call', {
name: 'hermes_studio_api_openapi_get',
arguments: {},
})
writeRpc(child, 10, 'tools/call', {
name: 'hermes_api_request',
arguments: {
method: 'POST',
path: '/api/test-public-requester',
body: { legacy: true },
},
})
const initialized = await waitForRpc(responses, 1)
expect(initialized.result.serverInfo).toMatchObject({
name: 'hermes-studio-mcp',
version: pkg.version,
})
expect(initialized.result.capabilities).toEqual({ tools: {} })
const list = await waitForRpc(responses, 2)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_api_request')).toBe(true)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_lan_devices_list')).toBe(false)
const response = await waitForRpc(responses, 3)
const payload = JSON.parse(response.result.content[0].text)
expect(payload.status).toBe(200)
expect(payload.body).toMatchObject({
method: 'POST',
url: '/api/test-public-requester?q=hello',
body: { ok: true },
profile: 'research',
authorization: 'Bearer profile-token',
})
const legacyResponse = await waitForRpc(responses, 10)
const legacyPayload = JSON.parse(legacyResponse.result.content[0].text)
expect(legacyPayload.status).toBe(200)
expect(legacyPayload.body.body).toEqual({ legacy: true })
writeRpc(child, 8, 'tools/call', {
name: 'hermes_studio_api_openapi_get',
arguments: {
path: '/api/test-public-requester',
method: 'POST',
},
})
const resource = await waitForRpc(responses, 4)
expect(resource.error).toMatchObject({
code: -32601,
message: 'Method not found: resources/list',
})
const invalid = await waitForRpc(responses, 5)
expect(invalid.result.isError).toBe(true)
expect(invalid.result.content[0].text).toContain('missing required field body.input')
expect(chatRunHits).toBe(0)
const compactManual = await waitForRpc(responses, 7)
const compactPayload = JSON.parse(compactManual.result.content[0].text)
expect(compactPayload).toMatchObject({
moduleCount: 2,
operationCount: 1,
operations: [{
method: 'POST',
path: '/api/chat-run/runs',
requestBody: {
fields: [
{ name: 'input', required: true, type: 'string' },
],
},
}],
})
expect(compactPayload.paths).toBeUndefined()
const moduleManual = await waitForRpc(responses, 9)
const modulePayload = JSON.parse(moduleManual.result.content[0].text)
expect(modulePayload).toMatchObject({
moduleCount: 2,
operationCount: 2,
operationsOmitted: true,
modules: expect.arrayContaining([
{
tag: 'Chat Run',
operationCount: 1,
purpose: 'Start a chat or coding-agent run through the HTTP bridge and wait for the result.',
keywords: expect.arrayContaining(['chat', 'run']),
description: 'Chat run operations',
},
{ tag: 'Testing', operationCount: 1, description: 'Test helper operations' },
]),
})
expect(modulePayload.operations).toBeUndefined()
const optionalManual = await waitForRpc(responses, 8)
const optionalPayload = JSON.parse(optionalManual.result.content[0].text)
expect(optionalPayload.operations[0]).toMatchObject({
method: 'POST',
path: '/api/test-public-requester',
requestBody: { required: false, fields: [] },
})
writeRpc(child, 6, 'tools/call', {
name: 'hermes_studio_api_request',
arguments: {
method: 'POST',
path: '/api/chat-run/runs',
body: { input: 'hello' },
},
})
const valid = await waitForRpc(responses, 6)
const validPayload = JSON.parse(valid.result.content[0].text)
expect(validPayload.status).toBe(200)
expect(validPayload.body.body).toEqual({ input: 'hello' })
expect(chatRunHits).toBe(1)
await new Promise<void>(resolve => server.close(() => resolve()))
})
it.each([
'bin/hermes-studio-mcp.mjs',
'bin/hermes-web-ui-mcp.mjs',
])('reports the package version from the CLI entry %s', async (entry) => {
const child = spawn(process.execPath, [entry, '--version'], {
cwd: process.cwd(),
env: process.env,
})
let stdout = ''
child.stdout.on('data', chunk => { stdout += String(chunk) })
const code = await new Promise<number | null>(resolve => child.on('close', resolve))
expect(code).toBe(0)
expect(stdout.trim()).toBe(`hermes-studio-mcp v${pkg.version}`)
})
it('exposes curated Hermes Studio use tools in the use toolset', async () => {
const server = createServer((req, res) => {
res.setHeader('content-type', 'application/json')
if (req.url === '/api/chat-run/runs') {
let raw = ''
req.on('data', chunk => { raw += chunk })
req.on('end', () => {
res.end(JSON.stringify({
ok: true,
session_id: 'session-1',
body: raw ? JSON.parse(raw) : null,
}))
})
return
}
if (req.url === '/api/hermes/sessions?limit=2&source=coding_agent') {
res.end(JSON.stringify({ sessions: [{ id: 'session-1' }] }))
return
}
if (req.url === '/api/hermes/sessions/count?source=coding_agent') {
res.end(JSON.stringify({ count: 7 }))
return
}
if (req.url === '/api/hermes/usage/stats?days=7') {
res.end(JSON.stringify({
total_input_tokens: 100,
total_output_tokens: 40,
total_cache_read_tokens: 10,
total_cache_write_tokens: 2,
total_reasoning_tokens: 5,
total_sessions: 3,
total_cost: 0.0123,
total_api_calls: 4,
period_days: 7,
model_usage: [{ model: 'gpt-5.1', input_tokens: 100, output_tokens: 40, cache_read_tokens: 10, cache_write_tokens: 2, reasoning_tokens: 5, sessions: 3 }],
daily_usage: [],
}))
return
}
if (req.url === '/api/hermes/sessions/session-1' && req.method === 'GET') {
res.end(JSON.stringify({ id: 'session-1', title: 'Session 1' }))
return
}
if (req.url === '/api/hermes/sessions/session-1/context') {
res.end(JSON.stringify({
session_id: 'session-1',
title: 'Session 1',
messages: [
{ id: 1, role: 'user', content: 'older', timestamp: 1 },
{ id: 2, role: 'assistant', content: 'tool call shell', timestamp: 2, tool_calls: [{ id: 'call-1' }] },
{ id: 3, role: 'tool', content: 'secret tool result', timestamp: 3, tool_call_id: 'call-1', tool_name: 'read_file' },
{ id: 4, role: 'user', content: 'hello', timestamp: 4 },
{ id: 5, role: 'assistant', content: 'hi', timestamp: 5 },
{ id: 6, role: 'user', content: 'next', timestamp: 6 },
{ id: 7, role: 'assistant', content: 'done', timestamp: 7 },
],
message_count: 7,
}))
return
}
if (req.url === '/api/hermes/sessions/session-1' && req.method === 'DELETE') {
res.end(JSON.stringify({ ok: true, deleted: true }))
return
}
if (req.url === '/api/hermes/sessions/conversations/session-1/messages?humanOnly=0') {
res.end(JSON.stringify({ messages: [{ role: 'system', content: 'internal' }] }))
return
}
if (req.url === '/api/hermes/sessions/session-1/rename' && req.method === 'POST') {
let raw = ''
req.on('data', chunk => { raw += chunk })
req.on('end', () => {
res.end(JSON.stringify({ ok: true, body: raw ? JSON.parse(raw) : null }))
})
return
}
if (req.url === '/api/hermes/profiles') {
res.end(JSON.stringify({ profiles: ['default'] }))
return
}
if (req.url === '/api/hermes/available-models') {
res.end(JSON.stringify({
default: 'gpt-5.1',
default_provider: 'openai',
providers: [
{ id: 'openai', label: 'OpenAI', models: ['gpt-5.1'] },
{ id: 'openrouter', label: 'OpenRouter', models: ['gpt-5.1', 'claude-sonnet'], model_meta: { 'claude-sonnet': { alias: 'Claude Sonnet' } } },
],
}))
return
}
if (req.url === '/api/hermes/config/providers' && req.method === 'POST') {
let raw = ''
req.on('data', chunk => { raw += chunk })
req.on('end', () => {
res.end(JSON.stringify({
success: true,
body: raw ? JSON.parse(raw) : null,
}))
})
return
}
if (req.url === '/api/hermes/config/providers/custom%3Aedge-router?source=providers&providerKey=edge-router' && req.method === 'DELETE') {
res.end(JSON.stringify({ success: true, deleted: 'custom:edge-router' }))
return
}
if (req.url === '/api/hermes/performance/runtime') {
res.end(JSON.stringify({
timestamp: 123456,
bridge: {
reachable: true,
workers: [
{ profile: 'default', pid: 101, running: true, sessionCount: 3, runningSessionCount: 1, lastUsedAt: 123400, endpoint: 'ipc://default' },
{ profile: 'research', pid: 102, running: true, sessionCount: 2, runningSessionCount: 0, lastUsedAt: 123300, endpoint: 'ipc://research' },
],
},
sessions: { active: 5, running: 1, byProfile: { default: 3, research: 2 } },
}))
return
}
if (req.url === '/api/hermes/workflows?profile=default') {
res.end(JSON.stringify({ workflows: [{ id: 'workflow-1', name: 'Demo workflow', profile: 'default' }] }))
return
}
if (req.url === '/api/hermes/workflows/workflow-1' && req.method === 'GET') {
res.end(JSON.stringify({ workflow: { id: 'workflow-1', name: 'Demo workflow', profile: 'default' } }))
return
}
if (req.url === '/api/hermes/workflows' && req.method === 'POST') {
let raw = ''
req.on('data', chunk => { raw += chunk })
req.on('end', () => {
res.end(JSON.stringify({ workflow: { id: 'workflow-created', body: raw ? JSON.parse(raw) : null } }))
})
return
}
if (req.url === '/api/hermes/workflows/workflow-1' && req.method === 'PATCH') {
let raw = ''
req.on('data', chunk => { raw += chunk })
req.on('end', () => {
res.end(JSON.stringify({ workflow: { id: 'workflow-1', body: raw ? JSON.parse(raw) : null } }))
})
return
}
if (req.url === '/api/hermes/workflows/workflow-1/runs?limit=5') {
res.end(JSON.stringify({ runs: [{ id: 'run-1', workflow_id: 'workflow-1', status: 'completed', node_sessions: [] }] }))
return
}
if (req.url === '/api/hermes/workflows/workflow-1/run' && req.method === 'POST') {
let raw = ''
req.on('data', chunk => { raw += chunk })
req.on('end', () => {
res.end(JSON.stringify({ ok: true, status: 'accepted', body: raw ? JSON.parse(raw) : null }))
})
return
}
if (req.url === '/api/hermes/workflows/workflow-1/runs/run-1/stop' && req.method === 'POST') {
res.end(JSON.stringify({ ok: true, run: { id: 'run-1', status: 'canceled' } }))
return
}
if (req.url === '/api/hermes/workflows/workflow-1/runs/run-1/rerun-from-node' && req.method === 'POST') {
let raw = ''
req.on('data', chunk => { raw += chunk })
req.on('end', () => {
res.end(JSON.stringify({ ok: true, status: 'accepted', body: raw ? JSON.parse(raw) : null }))
})
return
}
if (req.url === '/api/hermes/workflows/workflow-1/runs/run-1' && req.method === 'DELETE') {
res.end(JSON.stringify({ ok: true, deleted_run: 'run-1' }))
return
}
if (req.url === '/api/hermes/workflows/workflow-1' && req.method === 'DELETE') {
res.end(JSON.stringify({ ok: true, deleted_workflow: 'workflow-1' }))
return
}
res.statusCode = 404
res.end('{}')
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('expected TCP server address')
const responses = new Map<number, any>()
child = spawn(process.execPath, ['bin/hermes-studio-mcp.mjs', 'use'], {
cwd: process.cwd(),
env: {
...process.env,
HERMES_WEB_UI_URL: `http://127.0.0.1:${address.port}`,
},
})
child.stdout.on('data', (chunk) => {
for (const line of String(chunk).trim().split('\n')) {
if (!line) continue
const message = JSON.parse(line)
responses.set(message.id, message)
}
})
writeRpc(child, 1, 'initialize', {})
writeRpc(child, 2, 'tools/list')
writeRpc(child, 3, 'tools/call', {
name: 'hermes_studio_use_chat_run',
arguments: { input: 'hello', session_id: 'session-1', include_events: true },
})
writeRpc(child, 4, 'tools/call', {
name: 'hermes_studio_use_sessions_list',
arguments: { limit: 2, source: 'coding_agent' },
})
writeRpc(child, 5, 'tools/call', {
name: 'hermes_studio_use_session_get',
arguments: { session_id: 'session-1' },
})
writeRpc(child, 10, 'tools/call', {
name: 'hermes_studio_use_sessions_count',
arguments: { source: 'coding_agent' },
})
writeRpc(child, 6, 'tools/call', {
name: 'hermes_studio_use_session_messages',
arguments: { session_id: 'session-1', include_internal: true },
})
writeRpc(child, 11, 'tools/call', {
name: 'hermes_studio_use_session_rename',
arguments: { session_id: 'session-1', title: 'Renamed session' },
})
writeRpc(child, 12, 'tools/call', {
name: 'hermes_studio_use_session_delete',
arguments: { session_id: 'session-1' },
})
writeRpc(child, 7, 'tools/call', {
name: 'hermes_studio_use_profiles_list',
arguments: {},
})
writeRpc(child, 8, 'tools/call', {
name: 'hermes_studio_use_available_models',
arguments: {},
})
writeRpc(child, 30, 'tools/call', {
name: 'hermes_studio_use_available_models',
arguments: { query: 'claude', limit_per_provider: 1 },
})
writeRpc(child, 31, 'tools/call', {
name: 'hermes_studio_use_available_models',
arguments: { include_details: true },
})
writeRpc(child, 13, 'tools/call', {
name: 'hermes_studio_use_model_provider_get',
arguments: { model: 'gpt-5.1' },
})
writeRpc(child, 14, 'tools/call', {
name: 'hermes_studio_use_model_provider_get',
arguments: { model: 'Claude Sonnet' },
})
writeRpc(child, 15, 'tools/call', {
name: 'hermes_studio_use_provider_add',
arguments: {
name: 'Edge Router',
base_url: 'https://edge.example/v1',
api_key: 'secret-key',
model: 'edge-model',
context_length: 128000,
api_mode: 'chat_completions',
},
})
writeRpc(child, 16, 'tools/call', {
name: 'hermes_studio_use_provider_delete',
arguments: {
pool_key: 'custom:edge-router',
source: 'providers',
providerKey: 'edge-router',
},
})
writeRpc(child, 17, 'tools/call', {
name: 'hermes_studio_use_usage_stats',
arguments: { days: 7 },
})
writeRpc(child, 18, 'tools/call', {
name: 'hermes_studio_use_session_context',
arguments: { session_id: 'session-1', turns: 1 },
})
writeRpc(child, 19, 'tools/call', {
name: 'hermes_studio_use_worker_status',
arguments: {},
})
writeRpc(child, 20, 'tools/call', {
name: 'hermes_studio_use_workflows_list',
arguments: { profile: 'default' },
})
writeRpc(child, 21, 'tools/call', {
name: 'hermes_studio_use_workflow_get',
arguments: { workflow_id: 'workflow-1' },
})
writeRpc(child, 22, 'tools/call', {
name: 'hermes_studio_use_workflow_create',
arguments: {
name: 'Created workflow',
profile: 'default',
workspace: '/tmp/workflow',
nodes: [{ id: 'node-1', type: 'agent' }],
edges: [],
viewport: { x: 1, y: 2, zoom: 0.8 },
},
})
writeRpc(child, 23, 'tools/call', {
name: 'hermes_studio_use_workflow_update',
arguments: { workflow_id: 'workflow-1', name: 'Updated workflow', nodes: [{ id: 'node-2' }] },
})
writeRpc(child, 24, 'tools/call', {
name: 'hermes_studio_use_workflow_runs_list',
arguments: { workflow_id: 'workflow-1', limit: 5 },
})
writeRpc(child, 25, 'tools/call', {
name: 'hermes_studio_use_workflow_run_start',
arguments: { workflow_id: 'workflow-1', start_node_ids: ['node-1'], input: 'go', timeout_ms: 1000 },
})
writeRpc(child, 26, 'tools/call', {
name: 'hermes_studio_use_workflow_run_stop',
arguments: { workflow_id: 'workflow-1', run_id: 'run-1' },
})
writeRpc(child, 27, 'tools/call', {
name: 'hermes_studio_use_workflow_run_rerun_from_node',
arguments: { workflow_id: 'workflow-1', run_id: 'run-1', node_id: 'node-2', preserve_start_node: true, timeout_ms: 2000 },
})
writeRpc(child, 28, 'tools/call', {
name: 'hermes_studio_use_workflow_run_delete',
arguments: { workflow_id: 'workflow-1', run_id: 'run-1' },
})
writeRpc(child, 29, 'tools/call', {
name: 'hermes_studio_use_workflow_delete',
arguments: { workflow_id: 'workflow-1' },
})
const initialized = await waitForRpc(responses, 1)
expect(initialized.result.serverInfo).toMatchObject({ toolset: 'use' })
const list = await waitForRpc(responses, 2)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_use_chat_run')).toBe(true)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_use_sessions_count')).toBe(true)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_use_usage_stats')).toBe(true)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_use_session_context')).toBe(true)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_use_session_delete')).toBe(true)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_use_session_rename')).toBe(true)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_use_model_provider_get')).toBe(true)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_use_provider_add')).toBe(true)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_use_provider_delete')).toBe(true)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_use_worker_status')).toBe(true)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_use_workflows_list')).toBe(true)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_use_workflow_run_rerun_from_node')).toBe(true)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_api_request')).toBe(false)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_lan_devices_list')).toBe(false)
expect(list.result.tools.find((tool: any) => tool.name === 'hermes_studio_use_chat_run')?.description).toContain('internal delegation')
expect(list.result.tools.find((tool: any) => tool.name === 'hermes_studio_use_session_context')?.description).toContain('internal delegation')
const chatRun = JSON.parse((await waitForRpc(responses, 3)).result.content[0].text)
expect(chatRun.body).toMatchObject({ input: 'hello', session_id: 'session-1', include_events: true })
const sessions = JSON.parse((await waitForRpc(responses, 4)).result.content[0].text)
expect(sessions.sessions[0].id).toBe('session-1')
const sessionCount = JSON.parse((await waitForRpc(responses, 10)).result.content[0].text)
expect(sessionCount.count).toBe(7)
const session = JSON.parse((await waitForRpc(responses, 5)).result.content[0].text)
expect(session.title).toBe('Session 1')
const messages = JSON.parse((await waitForRpc(responses, 6)).result.content[0].text)
expect(messages.messages[0].role).toBe('system')
const renamed = JSON.parse((await waitForRpc(responses, 11)).result.content[0].text)
expect(renamed).toEqual({ ok: true, body: { title: 'Renamed session' } })
const deleted = JSON.parse((await waitForRpc(responses, 12)).result.content[0].text)
expect(deleted).toEqual({ ok: true, deleted: true })
const profiles = JSON.parse((await waitForRpc(responses, 7)).result.content[0].text)
expect(profiles.profiles).toEqual(['default'])
const models = JSON.parse((await waitForRpc(responses, 8)).result.content[0].text)
expect(models).toMatchObject({
default: 'gpt-5.1',
default_provider: 'openai',
provider_count: 2,
model_count: 3,
returned_model_count: 3,
})
expect(models.providers[0].models).toEqual(['gpt-5.1'])
expect(JSON.stringify(models)).not.toContain('model_meta')
const filteredModels = JSON.parse((await waitForRpc(responses, 30)).result.content[0].text)
expect(filteredModels).toMatchObject({
query: 'claude',
returned_provider_count: 1,
returned_model_count: 1,
providers: [
{
provider: 'openrouter',
models: ['claude-sonnet'],
aliases: { 'claude-sonnet': 'Claude Sonnet' },
},
],
})
const detailedModels = JSON.parse((await waitForRpc(responses, 31)).result.content[0].text)
expect(detailedModels.providers[1].model_meta).toEqual({ 'claude-sonnet': { alias: 'Claude Sonnet' } })
const modelProvider = JSON.parse((await waitForRpc(responses, 13)).result.content[0].text)
expect(modelProvider).toMatchObject({
model: 'gpt-5.1',
found: true,
provider: 'openai',
ambiguous: true,
})
expect(modelProvider.providers.map((entry: any) => entry.provider)).toEqual(['openai', 'openrouter'])
const aliasProvider = JSON.parse((await waitForRpc(responses, 14)).result.content[0].text)
expect(aliasProvider).toMatchObject({
model: 'Claude Sonnet',
found: true,
provider: 'openrouter',
ambiguous: false,
})
const addedProvider = JSON.parse((await waitForRpc(responses, 15)).result.content[0].text)
expect(addedProvider).toEqual({
success: true,
body: {
name: 'Edge Router',
base_url: 'https://edge.example/v1',
api_key: 'secret-key',
model: 'edge-model',
context_length: 128000,
api_mode: 'chat_completions',
},
})
const deletedProvider = JSON.parse((await waitForRpc(responses, 16)).result.content[0].text)
expect(deletedProvider).toEqual({ success: true, deleted: 'custom:edge-router' })
const usage = JSON.parse((await waitForRpc(responses, 17)).result.content[0].text)
expect(usage).toMatchObject({ total_input_tokens: 100, total_output_tokens: 40, period_days: 7 })
const context = JSON.parse((await waitForRpc(responses, 18)).result.content[0].text)
expect(context).toMatchObject({
session_id: 'session-1',
message_count: 2,
clean_message_count: 5,
requested_turns: 1,
messages: [
{ id: 6, role: 'user', content: 'next', timestamp: 6 },
{ id: 7, role: 'assistant', content: 'done', timestamp: 7 },
],
})
expect(JSON.stringify(context)).not.toContain('tool_calls')
expect(JSON.stringify(context)).not.toContain('secret tool result')
const workerStatus = JSON.parse((await waitForRpc(responses, 19)).result.content[0].text)
expect(workerStatus).toMatchObject({
bridge_reachable: true,
worker_count: 2,
running_worker_count: 2,
session_count: 5,
interacting_session_count: 1,
completed_interaction_count: 4,
})
expect(workerStatus.interacting_workers.map((worker: any) => worker.profile)).toEqual(['default'])
expect(workerStatus.completed_workers.map((worker: any) => worker.profile)).toEqual(['research'])
const workflows = JSON.parse((await waitForRpc(responses, 20)).result.content[0].text)
expect(workflows.workflows[0]).toMatchObject({ id: 'workflow-1', profile: 'default' })
const workflow = JSON.parse((await waitForRpc(responses, 21)).result.content[0].text)
expect(workflow.workflow).toMatchObject({ id: 'workflow-1', name: 'Demo workflow' })
const createdWorkflow = JSON.parse((await waitForRpc(responses, 22)).result.content[0].text)
expect(createdWorkflow.workflow.body).toMatchObject({
name: 'Created workflow',
profile: 'default',
workspace: '/tmp/workflow',
nodes: [{ id: 'node-1', type: 'agent' }],
edges: [],
viewport: { x: 1, y: 2, zoom: 0.8 },
})
const updatedWorkflow = JSON.parse((await waitForRpc(responses, 23)).result.content[0].text)
expect(updatedWorkflow.workflow.body).toEqual({ name: 'Updated workflow', nodes: [{ id: 'node-2' }] })
const workflowRuns = JSON.parse((await waitForRpc(responses, 24)).result.content[0].text)
expect(workflowRuns.runs[0]).toMatchObject({ id: 'run-1', workflow_id: 'workflow-1' })
const startedWorkflowRun = JSON.parse((await waitForRpc(responses, 25)).result.content[0].text)
expect(startedWorkflowRun).toMatchObject({ ok: true, status: 'accepted' })
expect(startedWorkflowRun.body).toEqual({ start_node_ids: ['node-1'], input: 'go', timeout_ms: 1000 })
const stoppedWorkflowRun = JSON.parse((await waitForRpc(responses, 26)).result.content[0].text)
expect(stoppedWorkflowRun.run).toMatchObject({ id: 'run-1', status: 'canceled' })
const rerunWorkflowRun = JSON.parse((await waitForRpc(responses, 27)).result.content[0].text)
expect(rerunWorkflowRun.body).toEqual({ node_id: 'node-2', preserve_start_node: true, timeout_ms: 2000 })
const deletedWorkflowRun = JSON.parse((await waitForRpc(responses, 28)).result.content[0].text)
expect(deletedWorkflowRun).toEqual({ ok: true, deleted_run: 'run-1' })
const deletedWorkflow = JSON.parse((await waitForRpc(responses, 29)).result.content[0].text)
expect(deletedWorkflow).toEqual({ ok: true, deleted_workflow: 'workflow-1' })
await new Promise<void>(resolve => server.close(() => resolve()))
})
it('filters exposed tools by MCP toolset argument', async () => {
const server = createServer((req, res) => {
if (req.url === '/api/devices') {
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ devices: [{ id: 'device-1' }] }))
return
}
res.statusCode = 404
res.end('{}')
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('expected TCP server address')
const responses = new Map<number, any>()
child = spawn(process.execPath, ['bin/hermes-studio-mcp.mjs', 'devices'], {
cwd: process.cwd(),
env: {
...process.env,
HERMES_WEB_UI_URL: `http://127.0.0.1:${address.port}`,
},
})
child.stdout.on('data', (chunk) => {
for (const line of String(chunk).trim().split('\n')) {
if (!line) continue
const message = JSON.parse(line)
responses.set(message.id, message)
}
})
writeRpc(child, 1, 'initialize', {})
writeRpc(child, 2, 'tools/list')
writeRpc(child, 3, 'tools/call', {
name: 'hermes_studio_api_request',
arguments: { path: '/health' },
})
writeRpc(child, 4, 'tools/call', {
name: 'hermes_lan_devices_list',
arguments: {},
})
const initialized = await waitForRpc(responses, 1)
expect(initialized.result.serverInfo).toMatchObject({ toolset: 'devices' })
const list = await waitForRpc(responses, 2)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_lan_devices_list')).toBe(true)
expect(list.result.tools.some((tool: any) => tool.name === 'hermes_studio_api_request')).toBe(false)
const hiddenCall = await waitForRpc(responses, 3)
expect(hiddenCall.result.isError).toBe(true)
expect(hiddenCall.result.content[0].text).toContain("active 'devices' MCP toolset")
const legacyDevicesCall = await waitForRpc(responses, 4)
const legacyDevicesPayload = JSON.parse(legacyDevicesCall.result.content[0].text)
expect(legacyDevicesPayload.devices[0].id).toBe('device-1')
await new Promise<void>(resolve => server.close(() => resolve()))
})
})