feat: support dynamic MCP tool loading

This commit is contained in:
lofyer
2026-08-11 23:49:18 +08:00
parent 98d7166ab3
commit 6a44335238
15 changed files with 695 additions and 140 deletions
+1
View File
@@ -438,6 +438,7 @@ const api: DesktopApi = {
mcpServers: []
})),
testMcpServer: vi.fn(async () => ({
dynamicToolsSupported: false,
toolCount: 0,
tools: []
}))
+70 -38
View File
@@ -42,6 +42,7 @@ type McpEditor = {
name: string
description: string
enabled: boolean
allowDynamicTools: boolean
assignments: CapabilityAssignments
transport: McpTransport
command: string
@@ -55,6 +56,7 @@ const emptyEditor: McpEditor = {
name: '',
description: '',
enabled: true,
allowDynamicTools: false,
assignments: ['model'],
transport: 'stdio',
command: '',
@@ -70,6 +72,7 @@ function editorFromServer(server: McpServerSummary): McpEditor {
name: server.name,
description: server.description,
enabled: server.enabled,
allowDynamicTools: server.allowDynamicTools,
assignments: server.assignments.includes('model')
? ['model']
: [],
@@ -255,6 +258,7 @@ export function McpSettingsSection(): React.JSX.Element {
name: editor.name,
description: editor.description,
enabled: editor.enabled,
allowDynamicTools: editor.allowDynamicTools,
assignments: editor.assignments,
secret
}
@@ -1223,6 +1227,24 @@ export function McpSettingsSection(): React.JSX.Element {
/>
<span>{t('mcp.editor.enable')}</span>
</label>
<label className="toggle-row">
<input
aria-label={t('mcp.editor.allowDynamicTools')}
checked={editor.allowDynamicTools}
onChange={(event) =>
setEditor({
...editor,
allowDynamicTools: event.target.checked
})
}
role="switch"
type="checkbox"
/>
<span>{t('mcp.editor.allowDynamicTools')}</span>
</label>
<p className="settings-notice">
{t('mcp.editor.allowDynamicToolsDescription')}
</p>
<div className="runtime-assignments">
<small>{t('mcp.editor.assignTo')}</small>
{configurableMcpTargets.map(
@@ -1316,6 +1338,9 @@ export function McpSettingsSection(): React.JSX.Element {
{server.secretConfigured
? t('mcp.custom.encryptedToken')
: ''}
{server.allowDynamicTools
? t('mcp.custom.dynamicToolsEnabled')
: ''}
</small>
</div>
<span className="mcp-server-card__summary">
@@ -1400,44 +1425,51 @@ export function McpSettingsSection(): React.JSX.Element {
</span>
</div>
{result ? (
<section
aria-label={t('mcp.builtin.toolsAriaLabel', {
name: server.name
})}
className="mcp-server-tools"
>
<div className="mcp-server-tools__heading">
<strong>
{result.serverName || server.name}
{result.serverVersion
? ` ${result.serverVersion}`
: ''}
</strong>
<small>
{t('mcp.builtin.toolCount', {
count: result.toolCount
})}
</small>
</div>
{result.tools.length > 0 ? (
<ul>
{result.tools.map((tool) => (
<li key={tool.name}>
<div>
<code>{tool.name}</code>
</div>
{tool.description && (
<p>{tool.description}</p>
)}
</li>
))}
</ul>
) : (
<p className="settings-empty">
{t('mcp.custom.noTools')}
</p>
)}
</section>
<>
<p>
{result.dynamicToolsSupported
? t('mcp.custom.dynamicToolsSupported')
: t('mcp.custom.dynamicToolsUnsupported')}
</p>
<section
aria-label={t('mcp.builtin.toolsAriaLabel', {
name: server.name
})}
className="mcp-server-tools"
>
<div className="mcp-server-tools__heading">
<strong>
{result.serverName || server.name}
{result.serverVersion
? ` ${result.serverVersion}`
: ''}
</strong>
<small>
{t('mcp.builtin.toolCount', {
count: result.toolCount
})}
</small>
</div>
{result.tools.length > 0 ? (
<ul>
{result.tools.map((tool) => (
<li key={tool.name}>
<div>
<code>{tool.name}</code>
</div>
{tool.description && (
<p>{tool.description}</p>
)}
</li>
))}
</ul>
) : (
<p className="settings-empty">
{t('mcp.custom.noTools')}
</p>
)}
</section>
</>
) : (
<p className="settings-empty">
{t('mcp.custom.testHelp')}
+23
View File
@@ -465,6 +465,7 @@ describe('SettingsPanel runtime files', () => {
saveMcpServer,
removeMcpServer: vi.fn(async () => capabilitySnapshot),
testMcpServer: vi.fn(async () => ({
dynamicToolsSupported: false,
toolCount: 0,
tools: []
})),
@@ -2461,6 +2462,11 @@ describe('SettingsPanel runtime files', () => {
name: '启用此 MCP Server'
})
).toBeChecked()
expect(
within(dialog).getByRole('switch', {
name: '允许动态更新工具列表'
})
).not.toBeChecked()
expect(within(dialog).getByLabelText('模型')).toBeChecked()
expect(
within(dialog).queryByLabelText('OpenCode')
@@ -2519,6 +2525,11 @@ describe('SettingsPanel runtime files', () => {
fireEvent.change(within(dialog).getByLabelText('Bearer Token'), {
target: { value: ' token-with-significant-spaces ' }
})
fireEvent.click(
within(dialog).getByRole('switch', {
name: '允许动态更新工具列表'
})
)
fireEvent.click(saveButton)
await waitFor(() =>
expect(saveMcpServer).toHaveBeenCalledWith(
@@ -2527,6 +2538,7 @@ describe('SettingsPanel runtime files', () => {
name: '本地文件工具',
transport: 'http',
url: 'https://mcp.example.com/mcp',
allowDynamicTools: true,
secret: {
action: 'replace',
value: ' token-with-significant-spaces '
@@ -2563,6 +2575,7 @@ describe('SettingsPanel runtime files', () => {
name: '团队知识服务',
description: '公司内部 MCP',
enabled: true,
allowDynamicTools: true,
assignments: ['model'],
secretConfigured: true,
transport: 'http',
@@ -2593,6 +2606,11 @@ describe('SettingsPanel runtime files', () => {
'团队知识服务'
)
expect(within(dialog).getByLabelText('Bearer Token')).toHaveValue('')
expect(
within(dialog).getByRole('switch', {
name: '允许动态更新工具列表'
})
).toBeChecked()
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() => expect(editButton).toHaveFocus())
expect(
@@ -2609,6 +2627,7 @@ describe('SettingsPanel runtime files', () => {
name: '团队工具服务',
description: '公司内部工具',
enabled: true,
allowDynamicTools: true,
assignments: ['model'],
secretConfigured: false,
transport: 'http',
@@ -2621,6 +2640,7 @@ describe('SettingsPanel runtime files', () => {
).mockResolvedValueOnce({
serverName: 'Team MCP',
serverVersion: '1.2.0',
dynamicToolsSupported: true,
toolCount: 1,
tools: [
{
@@ -2651,6 +2671,9 @@ describe('SettingsPanel runtime files', () => {
screen.getByRole('button', { name: '测试 团队工具服务' })
)
expect(await screen.findByText('team_search')).toBeInTheDocument()
expect(
screen.getByText('服务端支持动态更新工具列表')
).toBeInTheDocument()
expect(serverToggle).toHaveAttribute('aria-expanded', 'true')
expect(
screen.getByRole('region', { name: '团队工具服务 工具' })
@@ -275,6 +275,9 @@ export const integrations = {
optional: 'Optional',
clearToken: 'Clear the saved Bearer Token when saving',
enable: 'Enable this MCP server',
allowDynamicTools: 'Allow dynamic tool-list updates',
allowDynamicToolsDescription:
'Applies only when a trusted server advertises support. Updated tools are revalidated and used in the next model round, and existing approval controls still apply.',
assignTo: 'Assign to',
cancel: 'Cancel',
saving: 'Saving…',
@@ -291,6 +294,10 @@ export const integrations = {
enabled: 'Enabled',
disabled: 'Disabled',
encryptedToken: ' · Encrypted token',
dynamicToolsEnabled: ' · Dynamic tools allowed',
dynamicToolsSupported: 'Server supports dynamic tool-list updates',
dynamicToolsUnsupported:
'Server does not advertise dynamic tool-list updates',
toolsUndetected: 'Tools not checked',
testAriaLabel: 'Test {{name}}',
test: 'Test',
@@ -260,6 +260,9 @@ export const integrations = {
optional: '可选',
clearToken: '保存时清除已保存的 Bearer Token',
enable: '启用此 MCP Server',
allowDynamicTools: '允许动态更新工具列表',
allowDynamicToolsDescription:
'仅在可信 Server 声明支持时生效。工具列表变化后会重新验证并在下一轮模型请求中使用,新工具仍需经过现有审批。',
assignTo: '分配给',
cancel: '取消',
saving: '保存中…',
@@ -276,6 +279,9 @@ export const integrations = {
enabled: '已启用',
disabled: '已停用',
encryptedToken: ' · 已加密令牌',
dynamicToolsEnabled: ' · 允许动态工具',
dynamicToolsSupported: '服务端支持动态更新工具列表',
dynamicToolsUnsupported: '服务端未声明支持动态更新工具列表',
toolsUndetected: '工具未检测',
testAriaLabel: '测试 {{name}}',
test: '测试',