feat: support dynamic MCP tool loading
This commit is contained in:
@@ -438,6 +438,7 @@ const api: DesktopApi = {
|
||||
mcpServers: []
|
||||
})),
|
||||
testMcpServer: vi.fn(async () => ({
|
||||
dynamicToolsSupported: false,
|
||||
toolCount: 0,
|
||||
tools: []
|
||||
}))
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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: '测试',
|
||||
|
||||
Reference in New Issue
Block a user