[codex] add MCP tools visibility management (#1170)

* feat(mcp): add tools visibility management

## Features
- Tools visibility modal with 3 modes: All, Include, Exclude
- 'Manage Tools' button on McpServerCard (enabled only when connected)
- 'Fetch Tools List' button to refresh available tools (raw mode)
- Responsive design for mobile (480px), tablet (768px), desktop (1280px)
- i18n translations for 9 languages (zh/en/zh-TW/ja/ko/de/es/fr/pt)

## Technical Details
- Add raw parameter to fetchMcpTools API for unfiltered tools
- Pass raw parameter through controller → bridgeMcpAction → client
- Backend _mcp_tools_list supports raw_mode to skip include/exclude filter
- 28 MCP unit tests pass (23 controller + 5 bridge action)

## Files Changed
- McpManagerView.vue: Tools visibility modal with mode selector
- McpServerCard.vue: Add manage tools button
- mcp.ts (client): Add raw parameter to fetchMcpTools
- mcp.ts (controller): Pass raw parameter to bridge
- mcp.ts (services): Pass raw parameter to client.mcpTools
- client.ts: Add raw parameter to mcpTools
- hermes_bridge.py: Support raw_mode in _mcp_tools_list
- 9 locale files: Add 14 translation keys each
- mcp-controller.test.ts: Add 3 new test cases
- bridge-mcp-action.test.ts: New test file for parameter passing

* Delete projects directory

chore: remove accidentally committed projects/ directory

* fix MCP tools visibility edge cases

* remove MCP docs screenshots

---------

Co-authored-by: Crafter-feng <succeed_happu@163.com>
Co-authored-by: Crafter-feng <37255449+Crafter-feng@users.noreply.github.com>
This commit is contained in:
ekko
2026-05-31 09:00:38 +08:00
committed by GitHub
parent 9df79c33be
commit c998a53566
29 changed files with 703 additions and 189 deletions
@@ -0,0 +1,108 @@
import { execFileSync } from 'child_process'
import { describe, expect, it } from 'vitest'
function runPython(script: string): any {
try {
const output = execFileSync('python3', ['-c', script], {
cwd: process.cwd(),
encoding: 'utf-8',
stdio: 'pipe',
})
return JSON.parse(output)
} catch (error) {
const err = error as { stdout?: string; stderr?: string; message?: string }
throw new Error([
err.message || 'Python bridge MCP filter script failed',
err.stdout ? `stdout:\n${err.stdout}` : '',
err.stderr ? `stderr:\n${err.stderr}` : '',
].filter(Boolean).join('\n\n'))
}
}
describe('agent bridge MCP tools filtering', () => {
it('treats an empty include list as an active filter and keeps raw listing unfiltered', () => {
const result = runPython(String.raw`
import importlib.util
import json
import sys
import threading
from pathlib import Path
path = Path("packages/server/src/services/hermes/agent-bridge/hermes_bridge.py")
spec = importlib.util.spec_from_file_location("hermes_bridge", path)
bridge = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = bridge
spec.loader.exec_module(bridge)
class Tool:
def __init__(self, name):
self.name = name
self.description = f"{name} description"
self.inputSchema = {"type": "object"}
class Task:
_task = None
_error = None
def __init__(self):
self._tools = [Tool("read_file"), Tool("write_file"), Tool("delete_file")]
self._registered_tool_names = ["read_file", "write_file", "delete_file"]
self._config = {"command": "mcp-server"}
server = bridge.BridgeServer("tcp://127.0.0.1:0")
servers = {"fs": Task()}
lock = threading.RLock()
def names(response):
return [tool["name"] for tool in response["results"][0]["tools"]]
server._read_mcp_config = lambda profile: {
"mcp_servers": {
"fs": {
"command": "mcp-server",
"tools": {"include": []},
},
},
}
include_empty = server._mcp_tools_list({"server": "fs"}, "default", servers, lock)
include_empty_list = server._mcp_list("default", servers, lock)
include_empty_raw = server._mcp_tools_list({"server": "fs", "raw": True}, "default", servers, lock)
server._read_mcp_config = lambda profile: {
"mcp_servers": {
"fs": {
"command": "mcp-server",
"tools": {"include": ["read_file"]},
},
},
}
include_one = server._mcp_tools_list({"server": "fs"}, "default", servers, lock)
server._read_mcp_config = lambda profile: {
"mcp_servers": {
"fs": {
"command": "mcp-server",
"tools": {"exclude": ["delete_file"]},
},
},
}
exclude_one = server._mcp_tools_list({"server": "fs"}, "default", servers, lock)
print(json.dumps({
"include_empty": names(include_empty),
"include_empty_details": include_empty_list["servers"][0]["tool_details"],
"include_empty_raw": names(include_empty_raw),
"include_one": names(include_one),
"exclude_one": names(exclude_one),
}))
`)
expect(result).toEqual({
include_empty: [],
include_empty_details: [],
include_empty_raw: ['read_file', 'write_file', 'delete_file'],
include_one: ['read_file'],
exclude_one: ['read_file', 'write_file'],
})
})
})
+56
View File
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ── Mocks ──────────────────────────────────────────────────
const mcpToolsMock = vi.fn()
vi.mock('../../packages/server/src/services/hermes/agent-bridge/client', () => ({
AgentBridgeClient: vi.fn().mockImplementation(() => ({
mcpTools: mcpToolsMock,
})),
}))
vi.mock('../../packages/server/src/services/logger', () => ({
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}))
// ── Tests ──────────────────────────────────────────────────
describe('bridgeMcpAction - mcp_tools_list', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('passes server and profile to client.mcpTools', async () => {
mcpToolsMock.mockResolvedValue({ ok: true, results: [] })
const { bridgeMcpAction } = await import('../../packages/server/src/services/hermes/mcp')
await bridgeMcpAction('mcp_tools_list', { server: 'github' }, 'test-profile')
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile', undefined)
})
it('passes raw=true to client.mcpTools', async () => {
mcpToolsMock.mockResolvedValue({ ok: true, results: [] })
const { bridgeMcpAction } = await import('../../packages/server/src/services/hermes/mcp')
await bridgeMcpAction('mcp_tools_list', { server: 'github', raw: true }, 'test-profile')
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile', true)
})
it('passes raw=false to client.mcpTools', async () => {
mcpToolsMock.mockResolvedValue({ ok: true, results: [] })
const { bridgeMcpAction } = await import('../../packages/server/src/services/hermes/mcp')
await bridgeMcpAction('mcp_tools_list', { server: 'github', raw: false }, 'test-profile')
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile', false)
})
it('passes undefined server when not provided', async () => {
mcpToolsMock.mockResolvedValue({ ok: true, results: [] })
const { bridgeMcpAction } = await import('../../packages/server/src/services/hermes/mcp')
await bridgeMcpAction('mcp_tools_list', {}, 'test-profile')
expect(mcpToolsMock).toHaveBeenCalledWith(undefined, 'test-profile', undefined)
})
it('passes undefined profile when not provided', async () => {
mcpToolsMock.mockResolvedValue({ ok: true, results: [] })
const { bridgeMcpAction } = await import('../../packages/server/src/services/hermes/mcp')
await bridgeMcpAction('mcp_tools_list', { server: 'github' })
expect(mcpToolsMock).toHaveBeenCalledWith('github', undefined, undefined)
})
})
+57 -2
View File
@@ -151,6 +151,53 @@ describe('MCP Controller', () => {
await updateServer(ctx)
expect(ctx.status).toBe(400)
})
it('sends tools.include config for include mode', async () => {
mcpUpdateMock.mockResolvedValue({ ok: true })
const { updateServer } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({
params: { name: 'github' },
request: { body: { config: { command: 'npx', args: ['-y', 'server'], tools: { include: ['read_file', 'write_file'] } } } },
})
await updateServer(ctx)
expect(mcpUpdateMock).toHaveBeenCalledWith('github', {
command: 'npx',
args: ['-y', 'server'],
tools: { include: ['read_file', 'write_file'] },
}, 'test-profile')
expect(ctx.body).toEqual({ ok: true })
})
it('sends tools.exclude config for exclude mode', async () => {
mcpUpdateMock.mockResolvedValue({ ok: true })
const { updateServer } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({
params: { name: 'github' },
request: { body: { config: { command: 'npx', args: ['-y', 'server'], tools: { exclude: ['delete_file'] } } } },
})
await updateServer(ctx)
expect(mcpUpdateMock).toHaveBeenCalledWith('github', {
command: 'npx',
args: ['-y', 'server'],
tools: { exclude: ['delete_file'] },
}, 'test-profile')
expect(ctx.body).toEqual({ ok: true })
})
it('sends config without tools field for all mode', async () => {
mcpUpdateMock.mockResolvedValue({ ok: true })
const { updateServer } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({
params: { name: 'github' },
request: { body: { config: { command: 'npx', args: ['-y', 'server'] } } },
})
await updateServer(ctx)
expect(mcpUpdateMock).toHaveBeenCalledWith('github', {
command: 'npx',
args: ['-y', 'server'],
}, 'test-profile')
expect(ctx.body).toEqual({ ok: true })
})
})
describe('removeServer', () => {
@@ -180,7 +227,7 @@ describe('MCP Controller', () => {
const { listTools } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({ query: {} })
await listTools(ctx)
expect(mcpToolsMock).toHaveBeenCalledWith(undefined, 'test-profile')
expect(mcpToolsMock).toHaveBeenCalledWith(undefined, 'test-profile', undefined)
expect(ctx.body).toEqual(SAMPLE_TOOLS_RESPONSE)
})
@@ -189,7 +236,15 @@ describe('MCP Controller', () => {
const { listTools } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({ query: { server: 'github' } })
await listTools(ctx)
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile')
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile', undefined)
})
it('passes raw=true to get unfiltered tools', async () => {
mcpToolsMock.mockResolvedValue(SAMPLE_TOOLS_RESPONSE)
const { listTools } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({ query: { server: 'github', raw: '1' } })
await listTools(ctx)
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile', true)
})
it('returns 503 on bridge error', async () => {