Files
hermes-web-ui/tests/server/hermes-web-ui-mcp.test.ts

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()))
})
})