mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-14 14:12:44 +00:00
feat(mcp): first-class MCP tab — catalog, GUI auth/probe/logs, per-tool gating
A Cursor-style MCP manager inside Capabilities, plus the backend it needs. - Server list with brand/favicon avatars + live status dot and a capability summary (N tools, M prompts, K resources); Servers | Catalog views. - Catalog: one-click install of Nous-approved servers with required-env prompts. - GUI OAuth: Authenticate opens the system browser from the TTY-less backend and verifies a token actually lands; header/API-key servers are never pushed down OAuth; a dirty mcp.json can't drop a freshly-persisted auth field. - Full-width mcp.json editor (ecosystem document format) + pinned stdio/agent LogTail; probes cached 5m and keyed by (profile, config) so revisiting never respawns the fleet or shows a stale probe. - Whole-map persistence (PUT /api/mcp/servers) so deletes/toggles actually stick (the generic /api/config deep-merge could not remove keys). - perf: MCP probe/auth no longer hold the global skills lock, so a slow stdio spawn can't stall every other request into a 15s timeout. - per-tool include/exclude gating (lib/mcp-tool-filter) mirroring the CLI loader.
This commit is contained in:
parent
7e6d60aadc
commit
16aa09aca5
12 changed files with 1959 additions and 36 deletions
1481
apps/desktop/src/app/skills/mcp-tab.tsx
Normal file
1481
apps/desktop/src/app/skills/mcp-tab.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -73,9 +73,7 @@ describe('Hermes REST session helpers', () => {
|
|||
})
|
||||
|
||||
it('uses a longer timeout for active profile refresh during desktop startup', async () => {
|
||||
api
|
||||
.mockResolvedValueOnce({ current: 'default' })
|
||||
.mockResolvedValueOnce({ profiles: [] })
|
||||
api.mockResolvedValueOnce({ current: 'default' }).mockResolvedValueOnce({ profiles: [] })
|
||||
|
||||
await refreshActiveProfile()
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import type {
|
|||
LogsResponse,
|
||||
McpCatalogResponse,
|
||||
McpServerSummary,
|
||||
McpServerTestResponse,
|
||||
MemoryProviderConfig,
|
||||
MemoryProviderOAuthStatus,
|
||||
MemoryStatusResponse,
|
||||
|
|
@ -343,6 +342,7 @@ export function getLogs(params: {
|
|||
file?: string
|
||||
level?: string
|
||||
lines?: number
|
||||
search?: string
|
||||
}): Promise<LogsResponse> {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
|
|
@ -362,6 +362,10 @@ export function getLogs(params: {
|
|||
query.set('component', params.component)
|
||||
}
|
||||
|
||||
if (params.search) {
|
||||
query.set('search', params.search)
|
||||
}
|
||||
|
||||
const suffix = query.toString()
|
||||
|
||||
return window.hermesDesktop.api<LogsResponse>({
|
||||
|
|
@ -592,6 +596,49 @@ export function toggleSkill(name: string, enabled: boolean): Promise<{ ok: boole
|
|||
})
|
||||
}
|
||||
|
||||
export interface McpTestResult {
|
||||
ok: boolean
|
||||
error?: string
|
||||
tools: { name: string; description: string }[]
|
||||
/** Capability counts (absent on older backends / failed probes). */
|
||||
prompts?: number
|
||||
resources?: number
|
||||
}
|
||||
|
||||
/** Connect to the server, list its tools, disconnect. Slow (spawns/handshakes
|
||||
* for real) — well past the 15s default fetch timeout. */
|
||||
export function testMcpServer(name: string): Promise<McpTestResult> {
|
||||
return window.hermesDesktop.api<McpTestResult>({
|
||||
...profileScoped(),
|
||||
path: `/api/mcp/servers/${encodeURIComponent(name)}/test`,
|
||||
method: 'POST',
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
}
|
||||
|
||||
/** Replace the whole `mcp_servers` map (the mcp.json editor's save). Unlike
|
||||
* `saveHermesConfig`, this REPLACES rather than deep-merges, so deletes,
|
||||
* re-enables (dropping `enabled: false`), and removed nested fields persist. */
|
||||
export function saveMcpServers(servers: Record<string, Record<string, unknown>>): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
path: '/api/mcp/servers',
|
||||
method: 'PUT',
|
||||
body: { servers }
|
||||
})
|
||||
}
|
||||
|
||||
/** Run the OAuth flow for an HTTP server — opens the system browser and blocks
|
||||
* until the user finishes (or gives up), hence the very generous timeout. */
|
||||
export function authMcpServer(name: string): Promise<McpTestResult> {
|
||||
return window.hermesDesktop.api<McpTestResult>({
|
||||
...profileScoped(),
|
||||
path: `/api/mcp/servers/${encodeURIComponent(name)}/auth`,
|
||||
method: 'POST',
|
||||
timeoutMs: 300_000
|
||||
})
|
||||
}
|
||||
|
||||
export function getToolsets(): Promise<ToolsetInfo[]> {
|
||||
return window.hermesDesktop.api<ToolsetInfo[]>({
|
||||
...profileScoped(),
|
||||
|
|
@ -1033,16 +1080,6 @@ export function listMcpServers(): Promise<{ servers: McpServerSummary[] }> {
|
|||
})
|
||||
}
|
||||
|
||||
export function testMcpServer(name: string): Promise<McpServerTestResponse> {
|
||||
return window.hermesDesktop.api<McpServerTestResponse>({
|
||||
...profileScoped(),
|
||||
path: `/api/mcp/servers/${encodeURIComponent(name)}/test`,
|
||||
method: 'POST',
|
||||
// Connect + list tools can be slow for stdio servers that boot a process.
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
}
|
||||
|
||||
export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
|
|
|
|||
74
apps/desktop/src/lib/mcp-tool-filter.test.ts
Normal file
74
apps/desktop/src/lib/mcp-tool-filter.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { countEnabledTools, isToolEnabled, readToolsFilter, toggleToolInServer } from './mcp-tool-filter'
|
||||
|
||||
describe('readToolsFilter', () => {
|
||||
it('returns empty when no tools object', () => {
|
||||
expect(readToolsFilter({ command: 'x' })).toEqual({ exclude: undefined, include: undefined })
|
||||
})
|
||||
|
||||
it('reads include/exclude and ignores non-string entries', () => {
|
||||
expect(readToolsFilter({ tools: { exclude: ['c', 2], include: ['a', 'b', null] } })).toEqual({
|
||||
exclude: ['c'],
|
||||
include: ['a', 'b']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolEnabled', () => {
|
||||
it('enables everything with no filter', () => {
|
||||
expect(isToolEnabled({ command: 'x' }, 'anything')).toBe(true)
|
||||
})
|
||||
|
||||
it('include wins over exclude', () => {
|
||||
const server = { tools: { exclude: ['a'], include: ['a'] } }
|
||||
expect(isToolEnabled(server, 'a')).toBe(true)
|
||||
expect(isToolEnabled(server, 'b')).toBe(false)
|
||||
})
|
||||
|
||||
it('exclude disables listed tools', () => {
|
||||
const server = { tools: { exclude: ['b'] } }
|
||||
expect(isToolEnabled(server, 'a')).toBe(true)
|
||||
expect(isToolEnabled(server, 'b')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleToolInServer', () => {
|
||||
it('adds a fresh tool to a new exclude denylist when disabled', () => {
|
||||
const next = toggleToolInServer({ command: 'x' }, 'a')
|
||||
expect(next.tools).toEqual({ exclude: ['a'] })
|
||||
})
|
||||
|
||||
it('re-enabling removes the tool and drops the empty exclude/tools', () => {
|
||||
const next = toggleToolInServer({ command: 'x', tools: { exclude: ['a'] } }, 'a')
|
||||
expect(next.tools).toBeUndefined()
|
||||
})
|
||||
|
||||
it('respects include mode: toggling removes from include', () => {
|
||||
const next = toggleToolInServer({ tools: { include: ['a', 'b'] } }, 'a')
|
||||
expect(next.tools).toEqual({ include: ['b'] })
|
||||
})
|
||||
|
||||
it('respects include mode: re-enabling adds back to include', () => {
|
||||
const next = toggleToolInServer({ tools: { include: ['b'] } }, 'a')
|
||||
expect(next.tools).toEqual({ include: ['b', 'a'] })
|
||||
})
|
||||
|
||||
it('preserves sibling tools keys like resources/prompts', () => {
|
||||
const next = toggleToolInServer({ tools: { resources: false } }, 'a')
|
||||
expect(next.tools).toEqual({ exclude: ['a'], resources: false })
|
||||
})
|
||||
|
||||
it('does not mutate the input server', () => {
|
||||
const server = { tools: { exclude: ['a'] } }
|
||||
toggleToolInServer(server, 'b')
|
||||
expect(server.tools.exclude).toEqual(['a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('countEnabledTools', () => {
|
||||
it('counts enabled tools out of a discovered list', () => {
|
||||
const server = { tools: { exclude: ['b'] } }
|
||||
expect(countEnabledTools(server, ['a', 'b', 'c'])).toBe(2)
|
||||
})
|
||||
})
|
||||
61
apps/desktop/src/lib/mcp-tool-filter.ts
Normal file
61
apps/desktop/src/lib/mcp-tool-filter.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// Per-tool MCP gating. A server's optional `tools.include` (whitelist) /
|
||||
// `tools.exclude` (denylist) decide which discovered tools the agent registers
|
||||
// — `include` wins, no filter means all. Mirrors `_register_server_tools` in
|
||||
// `tools/mcp_tool.py`.
|
||||
|
||||
export interface McpToolsFilter {
|
||||
exclude?: string[]
|
||||
include?: string[]
|
||||
}
|
||||
|
||||
type ServerConfig = Record<string, unknown>
|
||||
|
||||
const asNames = (value: unknown): string[] | undefined =>
|
||||
Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : undefined
|
||||
|
||||
const toolsObject = (server: ServerConfig | null | undefined): Record<string, unknown> => {
|
||||
const tools = server?.tools
|
||||
|
||||
return tools && typeof tools === 'object' && !Array.isArray(tools) ? (tools as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
export function readToolsFilter(server: ServerConfig | null | undefined): McpToolsFilter {
|
||||
const tools = toolsObject(server)
|
||||
|
||||
return { exclude: asNames(tools.exclude), include: asNames(tools.include) }
|
||||
}
|
||||
|
||||
export function isToolEnabled(server: ServerConfig | null | undefined, name: string): boolean {
|
||||
const { exclude, include } = readToolsFilter(server)
|
||||
|
||||
return include?.length ? include.includes(name) : !exclude?.includes(name)
|
||||
}
|
||||
|
||||
// Toggle one tool, preserving the config's mode (include if present, else an
|
||||
// exclude denylist). Empty lists — and an emptied `tools` — are dropped.
|
||||
export function toggleToolInServer(server: ServerConfig, name: string): ServerConfig {
|
||||
const { exclude, include } = readToolsFilter(server)
|
||||
const key = include?.length ? 'include' : 'exclude'
|
||||
const current = (key === 'include' ? include : exclude) ?? []
|
||||
const names = current.includes(name) ? current.filter(n => n !== name) : [...current, name]
|
||||
const tools = { ...toolsObject(server) }
|
||||
|
||||
if (names.length) {
|
||||
tools[key] = names
|
||||
} else {
|
||||
delete tools[key]
|
||||
}
|
||||
|
||||
const next = { ...server }
|
||||
|
||||
if (Object.keys(tools).length) {
|
||||
next.tools = tools
|
||||
} else {
|
||||
delete next.tools
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export const countEnabledTools = (server: ServerConfig | null | undefined, names: string[]): number =>
|
||||
names.filter(name => isToolEnabled(server, name)).length
|
||||
|
|
@ -509,9 +509,17 @@ export interface AnalyticsResponse {
|
|||
summary: AnalyticsSkillsSummary
|
||||
top_skills: AnalyticsSkillEntry[]
|
||||
}
|
||||
/** Per-tool-name call counts. Absent on older backends. */
|
||||
tools?: AnalyticsToolEntry[]
|
||||
totals: AnalyticsTotals
|
||||
}
|
||||
|
||||
export interface AnalyticsToolEntry {
|
||||
count: number
|
||||
percentage: number
|
||||
tool: string
|
||||
}
|
||||
|
||||
export interface AnalyticsSkillEntry {
|
||||
last_used_at: null | number
|
||||
manage_count: number
|
||||
|
|
@ -640,6 +648,10 @@ export interface SkillInfo {
|
|||
description: string
|
||||
enabled: boolean
|
||||
name: string
|
||||
/** Total observed activity (use + view + patch). Absent on older backends. */
|
||||
usage?: number
|
||||
/** 'agent' = learned/local (editable), 'bundled' = ships with Hermes, 'hub' = installed. */
|
||||
provenance?: 'agent' | 'bundled' | 'hub'
|
||||
}
|
||||
|
||||
export interface ToolsetInfo {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue