Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/features/tool-quarantine.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,16 @@ appear at once.

The server list page shows a quarantine badge with the count of pending/changed tools for each server.

The server detail view's **Configuration** tab carries an **Auto-approve tool
changes** toggle (a **Tool-change approval** card) bound to the per-server
`auto_approve_tool_changes` flag. It is **OFF by default (protected)**; a ⚠️ hint
beneath it explains that enabling it disables rug-pull protection for that
server — future tool description/schema changes and newly added tools are then
trusted automatically instead of held for review. Toggling persists through the
existing `PATCH /api/v1/servers/{id}` path. (As noted under
[Per-Server Auto-Approve](#per-server-auto-approve), the runtime enforcement of
this flag rolls out in an upcoming release.)

### Doctor Command

The `mcpproxy doctor` command includes a "Tools Pending Quarantine Approval" section:
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ export interface Server {
protocol: 'http' | 'stdio' | 'streamable-http'
enabled: boolean
quarantined: boolean
// Per-server intent to auto-approve tool changes/additions (MCP-2930).
// When true, rug-pull protection is disabled for this server: changed and
// newly-added tools are trusted automatically instead of held for review.
// Optional because the REST status payload only includes it once the
// backend exposes the flag; absent/undefined is treated as OFF (protected).
auto_approve_tool_changes?: boolean
connected: boolean
connecting: boolean
authenticated?: boolean
Expand Down
75 changes: 75 additions & 0 deletions frontend/src/views/ServerDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,60 @@
</div>
</div>

<!-- Tool-change approval (rug-pull protection) — MCP-2932.
Bound to the per-server `auto_approve_tool_changes` config flag
(MCP-2930). OFF by default = protected: a tool whose
description/schema changes, or a newly-added tool, is held for
review before AI agents can use it. ON trusts those changes
automatically, disabling rug-pull protection for this server. -->
<div class="card bg-base-100 shadow-sm" data-test="auto-approve-card">
<div class="card-body py-4">
<h3 class="card-title text-base">Tool-change approval</h3>
<label class="flex items-center gap-3 mt-2 cursor-pointer">
<input
type="checkbox"
data-test="auto-approve-tool-changes"
:checked="autoApproveToolChanges"
@change="toggleAutoApproveToolChanges"
class="toggle toggle-sm toggle-warning"
:disabled="kvPatchInFlight"
/>
<span class="text-sm font-medium">Auto-approve tool changes</span>
</label>
<!-- Rug-pull warning sits directly beneath the toggle. Always
visible so the trade-off is clear before enabling; it
escalates to an alert once the protection is actually off. -->
<div
v-if="autoApproveToolChanges"
data-test="auto-approve-warning"
role="alert"
class="alert alert-warning mt-2 py-2 text-sm"
>
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>
Rug-pull protection is <strong>disabled</strong> for this server.
Future changes to a tool's description or schema — and newly
added tools — are trusted automatically instead of held for review.
</span>
</div>
<p
v-else
data-test="auto-approve-warning"
class="text-xs text-base-content/60 mt-2 flex items-start gap-1.5"
>
<span aria-hidden="true">⚠️</span>
<span>
Enabling this <strong>disables rug-pull protection</strong>: changed
tool descriptions/schemas and newly added tools will be trusted
automatically instead of held for review. Protected (default) is
recommended.
</span>
</p>
</div>
</div>

<!-- Connection (HTTP/SSE) -->
<div v-if="server.url" class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
Expand Down Expand Up @@ -2617,6 +2671,27 @@ function scopeKey(scope: 'header' | 'env'): 'headers' | 'env' {
return scope === 'header' ? 'headers' : 'env'
}

// MCP-2932: per-server "Auto-approve tool changes" toggle. Absent/undefined on
// the status payload is treated as OFF (protected) — see the Server type note.
const autoApproveToolChanges = computed(() => server.value?.auto_approve_tool_changes ?? false)

async function toggleAutoApproveToolChanges(event: Event) {
const checked = (event.target as HTMLInputElement).checked
// Persist through the existing PATCH /api/v1/servers/{id} path. The backend
// auto-approves changed/added tools on the next discovery pass for this
// server (MCP-2931); patchServerDiff surfaces the success toast.
const ok = await patchServerDiff(
{ auto_approve_tool_changes: checked },
checked ? 'Auto-approve tool changes enabled' : 'Auto-approve tool changes disabled'
)
// On failure, snap the checkbox back to the persisted value: patchServerDiff
// refetches servers on success, so the bound computed already reflects truth;
// an explicit no-op here keeps the control consistent with `server`.
if (!ok && event.target) {
;(event.target as HTMLInputElement).checked = autoApproveToolChanges.value
}
}

async function saveEdit(scope: 'header' | 'env', k: string, val: string) {
const ok = await patchServerDiff({ [scopeKey(scope)]: { [k]: val } }, `Updated ${k}`)
if (ok) editingKey.value = null
Expand Down
107 changes: 107 additions & 0 deletions frontend/tests/unit/server-detail-auto-approve.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createWebHistory } from 'vue-router'

// MCP-2932 (Spec 032 / parent MCP-2916): the server Configuration tab exposes an
// "Auto-approve tool changes" toggle bound to the per-server
// `auto_approve_tool_changes` config flag (MCP-2930). Enabling it disables
// rug-pull protection for that server, so a ⚠️ warning hint sits beneath it.
// Toggling persists through the existing PATCH /api/v1/servers/{id} path.

let serverAutoApprove = false

vi.mock('@/services/api', () => {
const ok = (data: unknown = {}) => Promise.resolve({ success: true, data })
return {
default: {
getServers: vi.fn(() =>
ok({
servers: [
{
name: 'github',
protocol: 'stdio',
enabled: true,
connected: true,
quarantined: false,
tool_count: 1,
auto_approve_tool_changes: serverAutoApprove,
},
],
})
),
getToolApprovals: vi.fn(() => ok({ tools: [], count: 0 })),
getToolDiff: vi.fn(() => ok({})),
getServerTools: vi.fn(() => ok({ tools: [] })),
getSecurityOverview: vi.fn(() => ok({})),
listScanners: vi.fn(() => ok({ scanners: [] })),
getServerLogs: vi.fn(() => ok({ logs: [] })),
discoverServerTools: vi.fn(() => ok({})),
patchServer: vi.fn(() => ok({})),
},
}
})

async function mountDetail() {
const api = (await import('@/services/api')).default
const ServerDetail = (await import('@/views/ServerDetail.vue')).default
const router = createRouter({
history: createWebHistory(),
routes: [{ path: '/servers/:serverName', component: { template: '<div/>' } }],
})
await router.push('/servers/github?tab=config')
await router.isReady()
const wrapper = mount(ServerDetail, {
props: { serverName: 'github' },
global: { plugins: [createPinia(), router] },
})
await flushPromises()
return { wrapper, api }
}

describe('ServerDetail — Auto-approve tool changes (MCP-2932)', () => {
beforeEach(() => {
setActivePinia(createPinia())
serverAutoApprove = false
})

it('renders the auto-approve checkbox and rug-pull warning hint', async () => {
const { wrapper } = await mountDetail()
expect(wrapper.find('[data-test="auto-approve-tool-changes"]').exists()).toBe(true)
expect(wrapper.find('[data-test="auto-approve-warning"]').exists()).toBe(true)
})

it('checkbox reflects the server auto_approve_tool_changes value (off by default)', async () => {
const { wrapper } = await mountDetail()
const cb = wrapper.find('[data-test="auto-approve-tool-changes"]')
.element as HTMLInputElement
expect(cb.checked).toBe(false)
})

it('checkbox reflects an enabled server flag', async () => {
serverAutoApprove = true
const { wrapper } = await mountDetail()
const cb = wrapper.find('[data-test="auto-approve-tool-changes"]')
.element as HTMLInputElement
expect(cb.checked).toBe(true)
})

it('toggling on persists via PATCH with auto_approve_tool_changes:true', async () => {
const { wrapper, api } = await mountDetail()
await wrapper.find('[data-test="auto-approve-tool-changes"]').setValue(true)
await flushPromises()
expect(api.patchServer).toHaveBeenCalledWith('github', {
auto_approve_tool_changes: true,
})
})

it('toggling off persists via PATCH with auto_approve_tool_changes:false', async () => {
serverAutoApprove = true
const { wrapper, api } = await mountDetail()
await wrapper.find('[data-test="auto-approve-tool-changes"]').setValue(false)
await flushPromises()
expect(api.patchServer).toHaveBeenCalledWith('github', {
auto_approve_tool_changes: false,
})
})
})
Loading