Skip to content

Commit 4186552

Browse files
committed
feat(webui): editable MCP instructions textarea in Advanced settings
Adds a 'MCP server instructions' accordion to Settings → Advanced with an editable textarea bound to the top-level `instructions` config key via the key-driven SettingsSection pattern (getPath/buildPartial). - New `textarea` control type in the settings field catalogue + SettingField. - Built-in default shown as the placeholder, fetched live from /api/v1/status `default_instructions` (never hardcoded in the frontend); degrades gracefully when an older core doesn't expose it. - Empty-means-default: clearing the box (incl. whitespace-only) persists "", which the backend maps back to the built-in default. - vitest unit tests for the textarea control + field catalogue; docs row note. Related #389 Related MCP-2175
1 parent 546a239 commit 4186552

7 files changed

Lines changed: 166 additions & 4 deletions

File tree

docs/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,8 @@ Text returned in the MCP `initialize` response to guide AI agents on how to use
770770
|-------|------|---------|-------------|
771771
| `instructions` | string | _(built-in)_ | Custom instructions sent in the MCP `initialize` response. When empty, a built-in default explains the `retrieve_tools``call_tool_*` workflow and warns against using `search_servers` for existing tools. |
772772

773+
You can edit this from the Web UI under **Settings → Advanced → MCP server instructions**. The textarea shows the built-in default as a greyed-out placeholder; clearing it restores that default.
774+
773775
**Note:** Applied at startup / on the next client connect — editing this value does not hot-reload into already-connected MCP sessions.
774776

775777
---

frontend/src/components/settings/SettingField.vue

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,20 @@
135135
<span v-if="validationError" class="text-error text-xs mt-1" :data-test="`setting-error-${field.key}`">{{ validationError }}</span>
136136
</div>
137137

138+
<!-- textarea (multi-line text, e.g. MCP server instructions) -->
139+
<div v-else-if="field.control === 'textarea'" class="flex flex-col items-stretch w-full sm:w-96">
140+
<textarea
141+
class="textarea textarea-bordered textarea-sm w-full font-mono leading-snug"
142+
:class="{ 'textarea-error': validationError }"
143+
rows="6"
144+
:value="modelValue ?? ''"
145+
:placeholder="field.placeholder"
146+
:data-test="`setting-textarea-${field.key}`"
147+
@input="emitText(($event.target as HTMLTextAreaElement).value)"
148+
></textarea>
149+
<span v-if="validationError" class="text-error text-xs mt-1" :data-test="`setting-error-${field.key}`">{{ validationError }}</span>
150+
</div>
151+
138152
<!-- multiselect (checkbox group) -->
139153
<div v-else-if="field.control === 'multiselect'" class="flex flex-wrap gap-3 justify-end max-w-xs">
140154
<label v-for="opt in field.options" :key="opt.value" class="label cursor-pointer gap-1 p-0">
@@ -210,6 +224,12 @@ function emitText(raw: string) {
210224
emitVal(null)
211225
return
212226
}
227+
// Empty-means-default: a textarea cleared to only whitespace persists as ""
228+
// (the backend maps "" → built-in default), never a whitespace-only string.
229+
if (props.field.control === 'textarea' && raw.trim() === '') {
230+
emitVal('')
231+
return
232+
}
213233
emitVal(raw)
214234
}
215235

frontend/src/services/api.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,11 @@ class APIService {
246246
}
247247

248248
// Status endpoint
249-
async getStatus(): Promise<APIResponse<{ edition: string; running: boolean; routing_mode: string }>> {
250-
return this.request<{ edition: string; running: boolean; routing_mode: string }>('/api/v1/status')
249+
// `default_instructions` is the resolved built-in MCP instructions default
250+
// (MCP-2175) — present once the backend exposes it; optional so the Web UI
251+
// degrades gracefully against older cores.
252+
async getStatus(): Promise<APIResponse<{ edition: string; running: boolean; routing_mode: string; default_instructions?: string }>> {
253+
return this.request<{ edition: string; running: boolean; routing_mode: string; default_instructions?: string }>('/api/v1/status')
251254
}
252255

253256
// Routing mode endpoint

frontend/src/views/Settings.vue

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ import {
193193
DOCS_BASE,
194194
docsUrl,
195195
type SettingField,
196+
type SettingsAccordion,
196197
} from '@/views/settings/fields'
197198
import api from '@/services/api'
198199
@@ -201,10 +202,28 @@ const systemStore = useSystemStore()
201202
202203
const securityFields = SECURITY_FIELDS
203204
const generalFields = GENERAL_FIELDS
204-
const advancedAccordions = ADVANCED_ACCORDIONS
205205
const serverEditionFields = SERVER_EDITION_FIELDS
206206
const serverEditionTitle = SERVER_EDITION_SECTION_TITLE
207207
208+
// Resolved built-in MCP `instructions` default (MCP-2175), fetched from the
209+
// backend so the `instructions` textarea placeholder never drifts from Go.
210+
const defaultInstructions = ref<string>('')
211+
212+
// Inject the live default as the `instructions` field placeholder. Until the
213+
// fetch resolves (or against an older core that doesn't expose it) the static
214+
// catalogue placeholder ("Loading built-in default…") is used.
215+
const advancedAccordions = computed<SettingsAccordion[]>(() =>
216+
ADVANCED_ACCORDIONS.map((acc) => {
217+
if (!defaultInstructions.value || !acc.fields.some((f) => f.key === 'instructions')) return acc
218+
return {
219+
...acc,
220+
fields: acc.fields.map((f) =>
221+
f.key === 'instructions' ? { ...f, placeholder: defaultInstructions.value } : f
222+
),
223+
}
224+
})
225+
)
226+
208227
// ---- form state ----
209228
const loading = ref(false)
210229
const loaded = ref(false)
@@ -224,7 +243,7 @@ const search = ref('')
224243
const allFields = computed<SettingField[]>(() => [
225244
...securityFields,
226245
...generalFields,
227-
...advancedAccordions.flatMap((a) => a.fields),
246+
...advancedAccordions.value.flatMap((a) => a.fields),
228247
...(hasServerEdition.value ? serverEditionFields : []),
229248
])
230249
const filteredFields = computed<SettingField[]>(() => {
@@ -399,8 +418,23 @@ function handleConfigSaved() {
399418
loadConfig()
400419
}
401420
421+
// Fetch the resolved built-in MCP instructions default for the textarea
422+
// placeholder (MCP-2175). Non-fatal: a failure or an older core just leaves the
423+
// static catalogue placeholder in place.
424+
async function loadDefaultInstructions() {
425+
try {
426+
const resp = await api.getStatus()
427+
if (resp.success && typeof resp.data?.default_instructions === 'string') {
428+
defaultInstructions.value = resp.data.default_instructions
429+
}
430+
} catch {
431+
/* leave placeholder as the static fallback */
432+
}
433+
}
434+
402435
onMounted(() => {
403436
loadConfig()
437+
loadDefaultInstructions()
404438
window.addEventListener('mcpproxy:config-saved', handleConfigSaved)
405439
})
406440
onUnmounted(() => {

frontend/src/views/settings/fields.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type ControlType =
99
| 'select'
1010
| 'number'
1111
| 'text'
12+
| 'textarea'
1213
| 'secret'
1314
| 'duration'
1415
| 'multiselect'
@@ -278,6 +279,26 @@ export const SERVER_EDITION_FIELDS: SettingField[] = [
278279

279280
// ---- Section 3: Advanced (subsystem accordions) ----
280281
export const ADVANCED_ACCORDIONS: SettingsAccordion[] = [
282+
{
283+
id: 'mcp',
284+
title: 'MCP server instructions',
285+
description:
286+
'Text sent to AI clients in the MCP initialize response, guiding how to use the proxy. Power-user, set-once option.',
287+
fields: [
288+
{
289+
key: 'instructions',
290+
label: 'Server instructions',
291+
// Empty saves "" — Go maps that back to the built-in default, which is
292+
// shown here as the placeholder (fetched live so it never drifts from
293+
// the backend). Applied at startup / on the next client connect; it
294+
// does not hot-reload into already-connected MCP sessions.
295+
help: 'Leave blank to use the built-in default (shown greyed-out below). Applied on the next client connect, not to already-connected sessions.',
296+
control: 'textarea',
297+
optional: true,
298+
placeholder: 'Loading built-in default…',
299+
},
300+
],
301+
},
281302
{
282303
id: 'code-execution',
283304
docs: '/features/code-execution',
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { mount } from '@vue/test-utils'
3+
import SettingField from '@/components/settings/SettingField.vue'
4+
import { ADVANCED_ACCORDIONS, validateField, type SettingField as Field } from '@/views/settings/fields'
5+
6+
// MCP-2175 — editable `instructions` textarea in Advanced settings.
7+
// The built-in default (Go: defaultInstructions) is shown as the placeholder
8+
// and clearing the box must persist "" (Go maps "" -> default), never whitespace.
9+
10+
const instrField: Field = {
11+
key: 'instructions',
12+
label: 'Server instructions',
13+
control: 'textarea',
14+
optional: true,
15+
placeholder: 'BUILT-IN DEFAULT',
16+
}
17+
18+
describe('SettingField textarea control', () => {
19+
it('renders a <textarea> with the default bound as placeholder', () => {
20+
const w = mount(SettingField, { props: { field: instrField, modelValue: '' } })
21+
const ta = w.find('[data-test="setting-textarea-instructions"]')
22+
expect(ta.exists()).toBe(true)
23+
expect(ta.element.tagName).toBe('TEXTAREA')
24+
expect(ta.attributes('placeholder')).toBe('BUILT-IN DEFAULT')
25+
})
26+
27+
it('emits "" when cleared to whitespace-only (empty-means-default)', async () => {
28+
const w = mount(SettingField, { props: { field: instrField, modelValue: 'old custom' } })
29+
const ta = w.find('[data-test="setting-textarea-instructions"]')
30+
await ta.setValue(' \n ')
31+
const emits = w.emitted('update:modelValue')
32+
expect(emits).toBeTruthy()
33+
expect(emits![emits!.length - 1][0]).toBe('')
34+
})
35+
36+
it('preserves real multi-line content verbatim', async () => {
37+
const w = mount(SettingField, { props: { field: instrField, modelValue: '' } })
38+
const ta = w.find('[data-test="setting-textarea-instructions"]')
39+
await ta.setValue('Use retrieve_tools first.\nThen call_tool_read.')
40+
const emits = w.emitted('update:modelValue')
41+
expect(emits![emits!.length - 1][0]).toBe('Use retrieve_tools first.\nThen call_tool_read.')
42+
})
43+
})
44+
45+
describe('instructions field catalogue (MCP-2175)', () => {
46+
it('exposes an Advanced "mcp" accordion with an optional textarea instructions field', () => {
47+
const mcp = ADVANCED_ACCORDIONS.find((a) => a.id === 'mcp')
48+
expect(mcp, 'expected an Advanced accordion with id "mcp"').toBeTruthy()
49+
const f = mcp!.fields.find((x) => x.key === 'instructions')
50+
expect(f, 'expected an "instructions" field in the mcp accordion').toBeTruthy()
51+
expect(f!.control).toBe('textarea')
52+
expect(f!.optional).toBe(true)
53+
})
54+
55+
it('treats any blank/whitespace instructions value as valid', () => {
56+
const f: Field = { key: 'instructions', label: 'x', control: 'textarea', optional: true }
57+
expect(validateField(f, '')).toBeNull()
58+
expect(validateField(f, ' ')).toBeNull()
59+
expect(validateField(f, 'custom instructions text')).toBeNull()
60+
})
61+
})

frontend/verification/mcp-2175/report.html

Lines changed: 21 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)