Skip to content
Closed
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
8 changes: 8 additions & 0 deletions frontend/src/components/settings/SettingField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@

<!-- textarea (multi-line text, e.g. MCP server instructions) -->
<div v-else-if="field.control === 'textarea'" class="flex flex-col items-stretch w-full sm:w-96">
<div v-if="field.resetDefault != null" class="flex justify-end mb-1">
<button
type="button"
class="btn btn-ghost btn-xs text-base-content/50 hover:text-base-content"
:data-test="`setting-reset-${field.key}`"
@click="emitText(field.resetDefault ?? '')"
>↩ Reset to default</button>
</div>
<textarea
class="textarea textarea-bordered textarea-sm w-full font-mono leading-snug"
:class="{ 'textarea-error': validationError }"
Expand Down
36 changes: 32 additions & 4 deletions frontend/src/views/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@
</template>

<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { useServersStore } from '@/stores/servers'
Expand Down Expand Up @@ -209,21 +209,39 @@ const serverEditionTitle = SERVER_EDITION_SECTION_TITLE
// backend so the `instructions` textarea placeholder never drifts from Go.
const defaultInstructions = ref<string>('')

// Inject the live default as the `instructions` field placeholder. Until the
// Inject the live default as the `instructions` field placeholder AND as the
// `resetDefault` value (drives the compact "Reset to default" button). Until the
// fetch resolves (or against an older core that doesn't expose it) the static
// catalogue placeholder ("Loading built-in default…") is used.
// catalogue placeholder ("Loading built-in default…") is used and no reset
// button is shown (MCP-2484).
const advancedAccordions = computed<SettingsAccordion[]>(() =>
ADVANCED_ACCORDIONS.map((acc) => {
if (!defaultInstructions.value || !acc.fields.some((f) => f.key === 'instructions')) return acc
return {
...acc,
fields: acc.fields.map((f) =>
f.key === 'instructions' ? { ...f, placeholder: defaultInstructions.value } : f
f.key === 'instructions'
? { ...f, placeholder: defaultInstructions.value, resetDefault: defaultInstructions.value }
: f
),
}
})
)

// Prefill the instructions textarea with the built-in default when the config
// field is empty (fresh install or blank override). Only runs when BOTH the
// config AND the default are loaded; whichever resolves second triggers it.
// Never overwrites a user's saved value — a non-empty `instructions` stays as-is
// (MCP-2484). Saving the prefilled text freezes the current default into the
// config; the "Reset to default" button is the re-sync path.
function maybePrefillInstructions() {
if (!defaultInstructions.value) return
if (!loaded.value) return
if (!state.working.instructions) {
state.working.instructions = defaultInstructions.value
}
}

// ---- form state ----
const loading = ref(false)
const loaded = ref(false)
Expand Down Expand Up @@ -332,6 +350,7 @@ async function loadConfig() {
configJson.value = JSON.stringify(cfg, null, 2)
configStatus.value = { valid: true }
loaded.value = true
maybePrefillInstructions()
} else {
loadError.value = response.error || 'Failed to load configuration'
}
Expand Down Expand Up @@ -426,12 +445,21 @@ async function loadDefaultInstructions() {
const resp = await api.getStatus()
if (resp.success && typeof resp.data?.default_instructions === 'string') {
defaultInstructions.value = resp.data.default_instructions
// In case loadConfig already resolved (loaded=true) before this fetch,
// prefill now — whichever of the two requests finishes second wins.
maybePrefillInstructions()
}
} catch {
/* leave placeholder as the static fallback */
}
}

// Also prefill if `defaultInstructions` resolves after the config has loaded
// (the common case where /api/v1/status is slower than /api/v1/config).
watch(defaultInstructions, () => {
maybePrefillInstructions()
})

onMounted(() => {
loadConfig()
loadDefaultInstructions()
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/views/settings/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export interface SettingField {
docs?: string // doc page path on docs.mcpproxy.app, e.g. "/features/docker-isolation"
valueKind?: ValueKind // extra format validation for text/secret fields
optional?: boolean // when true, an empty value is valid (skips kind validation)
// When set, the control renders a compact "Reset to default" button that
// repopulates the editable value with this text. Injected at runtime once the
// live default (e.g. the backend-resolved `instructions` default) is fetched
// (MCP-2484).
resetDefault?: string
}

export interface SettingsAccordion {
Expand Down
42 changes: 42 additions & 0 deletions frontend/tests/unit/settings-instructions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,45 @@ describe('instructions field catalogue (MCP-2175)', () => {
expect(validateField(f, 'custom instructions text')).toBeNull()
})
})

// MCP-2484 — a compact "Reset to default" button next to the instructions
// textarea. The live default (Go `defaultInstructions`, fetched from
// /api/v1/status) is injected onto the field as `resetDefault`; clicking the
// button repopulates the editable value (it flows through the normal change
// path so the section marks it dirty and Save persists it as `instructions`).

const resettableField: Field = {
key: 'instructions',
label: 'Server instructions',
control: 'textarea',
optional: true,
placeholder: 'BUILT-IN DEFAULT',
resetDefault: 'THE BUILT-IN DEFAULT TEXT',
}

describe('SettingField "Reset to default" (MCP-2484)', () => {
it('renders a compact reset button when the field carries a resetDefault', () => {
const w = mount(SettingField, { props: { field: resettableField, modelValue: 'custom' } })
expect(w.find('[data-test="setting-reset-instructions"]').exists()).toBe(true)
})

it('emits the default text when the reset button is clicked', async () => {
const w = mount(SettingField, { props: { field: resettableField, modelValue: 'custom edited' } })
await w.find('[data-test="setting-reset-instructions"]').trigger('click')
const emits = w.emitted('update:modelValue')
expect(emits).toBeTruthy()
expect(emits![emits!.length - 1][0]).toBe('THE BUILT-IN DEFAULT TEXT')
})

it('hides the reset button until the async default has loaded (no resetDefault)', () => {
const w = mount(SettingField, {
props: { field: { ...resettableField, resetDefault: undefined }, modelValue: '' },
})
expect(w.find('[data-test="setting-reset-instructions"]').exists()).toBe(false)
})

it('does not render a reset button on a field without resetDefault', () => {
const w = mount(SettingField, { props: { field: instrField, modelValue: '' } })
expect(w.find('[data-test="setting-reset-instructions"]').exists()).toBe(false)
})
})
Loading