Skip to content

Commit c86024b

Browse files
Dumbrisclaude
andcommitted
Merge origin/main into telemetry-optout-beacon (resolve Settings.vue)
Conflict from #685 (instructions prefill) + #681 (telemetry serialization) landing on main while this branch was in review. Resolved Settings.vue: - imports: union of watch (prefill) + nextTick/useRoute (banner focus deep-link) - onMounted: keep #685's watch(defaultInstructions) prefill trigger, use this branch's async onMounted for the focus query-param handling, drop the duplicate loadConfig() (body already awaits loadConfig()). Verified: 14 settings unit tests pass (instructions-prefill + prefill-dirty). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents ff6906a + b8f0d86 commit c86024b

17 files changed

Lines changed: 619 additions & 35 deletions

File tree

docs/setup.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,8 @@ Add mcpproxy as a remote MCP server via Settings → Connectors → Add Custom C
254254

255255
#### Option A: Free Plan — JSON Configuration
256256

257+
> **💡 One-click:** mcpproxy's built-in **Connect** wizard (Web UI / tray) can write this bridge configuration for you automatically — pick **Claude Desktop** and click **Connect**. It registers the `npx -y mcp-remote` bridge shown below (Node.js required). The manual steps remain available if you prefer to edit the file yourself.
258+
257259
1. Create the config file if it doesn't exist:
258260

259261
**macOS:**

frontend/src/components/ConnectModal.vue

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,12 @@
3333
<div class="min-w-0 flex-1">
3434
<div class="font-medium text-sm truncate">{{ client.name }}</div>
3535
<div class="text-xs opacity-50 truncate" :title="client.config_path">{{ client.config_path }}</div>
36+
<div v-if="client.note" class="text-xs opacity-60 italic mt-0.5" :title="client.note">{{ client.note }}</div>
3637
</div>
3738
</div>
3839
<div class="shrink-0 ml-2">
3940
<span v-if="!client.supported" class="badge badge-ghost badge-sm">{{ client.reason || 'Not supported' }}</span>
40-
<span v-else-if="!client.exists" class="text-xs opacity-40">Config not found</span>
41+
<span v-else-if="!client.exists && !client.bridge" class="text-xs opacity-40">Config not found</span>
4142
<button
4243
v-else-if="client.connected"
4344
@click="disconnect(client.id)"
@@ -114,7 +115,9 @@ const loading = reactive({
114115
})
115116
116117
const connectableClients = computed(() =>
117-
clients.value.filter(c => c.supported && c.exists && !c.connected)
118+
// Bridge clients (e.g. Claude Desktop) can be connected even without an
119+
// existing config file — Connect creates it.
120+
clients.value.filter(c => c.supported && (c.exists || c.bridge) && !c.connected)
118121
)
119122
120123
const allConnected = computed(() =>

frontend/src/components/settings/SettingField.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,14 @@
137137

138138
<!-- textarea (multi-line text, e.g. MCP server instructions) -->
139139
<div v-else-if="field.control === 'textarea'" class="flex flex-col items-stretch w-full sm:w-96">
140+
<div v-if="field.resetDefault != null" class="flex justify-end mb-1">
141+
<button
142+
type="button"
143+
class="btn btn-ghost btn-xs text-base-content/50 hover:text-base-content"
144+
:data-test="`setting-reset-${field.key}`"
145+
@click="emitText(field.resetDefault ?? '')"
146+
>↩ Reset to default</button>
147+
</div>
140148
<textarea
141149
class="textarea textarea-bordered textarea-sm w-full font-mono leading-snug"
142150
:class="{ 'textarea-error': validationError }"

frontend/src/components/settings/SettingsSection.vue

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
:key="f.key"
66
:field="f"
77
:model-value="getPath(working, f.key)"
8-
:dirty="!!dirty[f.key]"
8+
:dirty="isFieldDirty(f.key)"
99
@update:model-value="onChange(f, $event)"
1010
/>
1111
<p v-if="!fields.length" class="text-sm text-base-content/50 py-4">No settings match your search.</p>
@@ -98,7 +98,25 @@ const confirmEl = ref<HTMLDialogElement | null>(null)
9898
const pendingMessages = ref<string[]>([])
9999
const pendingInfoOnly = ref(false)
100100
101-
const dirtyKeys = computed(() => Object.keys(dirty.value))
101+
// A field is dirty if the user changed it through a control (tracked in the
102+
// `dirty` ref by onChange) OR its working value diverges from the last-saved
103+
// original. The latter catches values written onto `state.working` from OUTSIDE
104+
// a control — notably the instructions prefill in Settings.vue (MCP-2484).
105+
// Without it, "Save without editing" after a prefill would PATCH nothing because
106+
// the field was never marked dirty.
107+
const dirtyKeys = computed(() => {
108+
const keys = new Set(Object.keys(dirty.value))
109+
for (const f of props.fields) {
110+
if (!eq(getPath(props.working, f.key), getPath(props.original, f.key))) keys.add(f.key)
111+
}
112+
return [...keys]
113+
})
114+
115+
function isFieldDirty(key: string): boolean {
116+
if (key in dirty.value) return true
117+
const f = props.fields.find((x) => x.key === key)
118+
return f != null && !eq(getPath(props.working, key), getPath(props.original, key))
119+
}
102120
103121
// Block Save only when a CHANGED field is invalid — a pre-existing value the
104122
// user hasn't touched must never block saving unrelated edits.

frontend/src/types/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,8 @@ export interface ClientStatus {
795795
connected: boolean
796796
supported: boolean
797797
reason?: string
798+
note?: string
799+
bridge?: boolean
798800
icon: string
799801
server_name?: string
800802
}

frontend/src/views/Settings.vue

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@
174174
</template>
175175

176176
<script setup lang="ts">
177-
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
177+
import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
178178
import { RouterLink, useRoute } from 'vue-router'
179179
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
180180
import { useServersStore } from '@/stores/servers'
@@ -192,6 +192,7 @@ import {
192192
SERVER_EDITION_SECTION_TITLE,
193193
DOCS_BASE,
194194
docsUrl,
195+
isBlankInstructions,
195196
type SettingField,
196197
type SettingsAccordion,
197198
} from '@/views/settings/fields'
@@ -210,21 +211,35 @@ const serverEditionTitle = SERVER_EDITION_SECTION_TITLE
210211
// backend so the `instructions` textarea placeholder never drifts from Go.
211212
const defaultInstructions = ref<string>('')
212213
213-
// Inject the live default as the `instructions` field placeholder. Until the
214-
// fetch resolves (or against an older core that doesn't expose it) the static
215-
// catalogue placeholder ("Loading built-in default…") is used.
214+
// Inject the live default as the `instructions` field placeholder AND as the
215+
// resetDefault value (drives the "Reset to default" button). Until the fetch
216+
// resolves the static catalogue placeholder is used.
216217
const advancedAccordions = computed<SettingsAccordion[]>(() =>
217218
ADVANCED_ACCORDIONS.map((acc) => {
218219
if (!defaultInstructions.value || !acc.fields.some((f) => f.key === 'instructions')) return acc
219220
return {
220221
...acc,
221222
fields: acc.fields.map((f) =>
222-
f.key === 'instructions' ? { ...f, placeholder: defaultInstructions.value } : f
223+
f.key === 'instructions'
224+
? { ...f, placeholder: defaultInstructions.value, resetDefault: defaultInstructions.value }
225+
: f
223226
),
224227
}
225228
})
226229
)
227230
231+
// Prefill the instructions textarea with the built-in default when the config
232+
// field is empty (fresh install or blank override). Only runs when BOTH the
233+
// config AND the default are loaded; whichever resolves second triggers it.
234+
// Never overwrites a user's saved value (non-empty instructions stay as-is).
235+
function maybePrefillInstructions() {
236+
if (!defaultInstructions.value) return
237+
if (!loaded.value) return
238+
if (isBlankInstructions(state.working.instructions)) {
239+
state.working.instructions = defaultInstructions.value
240+
}
241+
}
242+
228243
// ---- form state ----
229244
const loading = ref(false)
230245
const loaded = ref(false)
@@ -333,6 +348,7 @@ async function loadConfig() {
333348
configJson.value = JSON.stringify(cfg, null, 2)
334349
configStatus.value = { valid: true }
335350
loaded.value = true
351+
maybePrefillInstructions()
336352
} else {
337353
loadError.value = response.error || 'Failed to load configuration'
338354
}
@@ -462,12 +478,20 @@ async function loadDefaultInstructions() {
462478
const resp = await api.getStatus()
463479
if (resp.success && typeof resp.data?.default_instructions === 'string') {
464480
defaultInstructions.value = resp.data.default_instructions
481+
// In case loadConfig already ran and set loaded=true, prefill now.
482+
maybePrefillInstructions()
465483
}
466484
} catch {
467485
/* leave placeholder as the static fallback */
468486
}
469487
}
470488
489+
// Also trigger prefill if defaultInstructions resolves after config is loaded
490+
// (handles the common case where /api/v1/status is slower than /api/v1/config).
491+
watch(defaultInstructions, () => {
492+
maybePrefillInstructions()
493+
})
494+
471495
onMounted(async () => {
472496
loadDefaultInstructions()
473497
window.addEventListener('mcpproxy:config-saved', handleConfigSaved)

frontend/src/views/settings/fields.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export interface SettingField {
4949
docs?: string // doc page path on docs.mcpproxy.app, e.g. "/features/docker-isolation"
5050
valueKind?: ValueKind // extra format validation for text/secret fields
5151
optional?: boolean // when true, an empty value is valid (skips kind validation)
52+
resetDefault?: string // when set, render an inline "Reset to default" button that emits this value
5253
}
5354

5455
export interface SettingsAccordion {
@@ -277,6 +278,12 @@ export const SERVER_EDITION_FIELDS: SettingField[] = [
277278
{ key: 'server_edition.max_user_servers', label: 'Max servers per user', control: 'number', min: 0 },
278279
]
279280

281+
// isBlankInstructions returns true when a saved instructions value is empty /
282+
// whitespace-only / null / undefined — i.e. eligible for prefill from the built-in default.
283+
export function isBlankInstructions(v: string | null | undefined): boolean {
284+
return !v || v.trim() === ''
285+
}
286+
280287
// ---- Section 3: Advanced (subsystem accordions) ----
281288
export const ADVANCED_ACCORDIONS: SettingsAccordion[] = [
282289
{

frontend/tests/unit/connect-modal.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,69 @@ describe('ConnectModal', () => {
9696
expect(wrapper.text()).toContain('⌘')
9797
})
9898

99+
it('renders a Connect button and bridge note for Claude Desktop', async () => {
100+
;(api.getConnectStatus as any).mockResolvedValue({
101+
success: true,
102+
data: [{
103+
id: 'claude-desktop',
104+
name: 'Claude Desktop',
105+
config_path: '/Users/test/Library/Application Support/Claude/claude_desktop_config.json',
106+
exists: true,
107+
connected: false,
108+
supported: true,
109+
note: 'Connects via an mcp-remote stdio bridge (npx -y mcp-remote). Requires Node.js.',
110+
icon: 'claude-desktop',
111+
}],
112+
})
113+
114+
const wrapper = mount(ConnectModal, {
115+
props: { show: false },
116+
global: { plugins: [pinia] },
117+
})
118+
119+
await wrapper.setProps({ show: true })
120+
await flushPromises()
121+
122+
// A real one-click Connect button must be offered (not greyed out).
123+
const connectButton = wrapper.find('button.btn-primary.btn-xs')
124+
expect(connectButton.exists()).toBe(true)
125+
expect(connectButton.text()).toContain('Connect')
126+
127+
// The bridge note must be surfaced to the user.
128+
expect(wrapper.text()).toContain('mcp-remote stdio bridge')
129+
})
130+
131+
it('shows Connect for a bridge client even when its config file does not exist', async () => {
132+
;(api.getConnectStatus as any).mockResolvedValue({
133+
success: true,
134+
data: [{
135+
id: 'claude-desktop',
136+
name: 'Claude Desktop',
137+
config_path: '/Users/test/Library/Application Support/Claude/claude_desktop_config.json',
138+
exists: false,
139+
connected: false,
140+
supported: true,
141+
bridge: true,
142+
note: 'Connects via an mcp-remote stdio bridge (npx -y mcp-remote). Requires Node.js.',
143+
icon: 'claude-desktop',
144+
}],
145+
})
146+
147+
const wrapper = mount(ConnectModal, {
148+
props: { show: false },
149+
global: { plugins: [pinia] },
150+
})
151+
152+
await wrapper.setProps({ show: true })
153+
await flushPromises()
154+
155+
// Fresh install: no config file yet, but the bridge Connect must still appear.
156+
const connectButton = wrapper.find('button.btn-primary.btn-xs')
157+
expect(connectButton.exists()).toBe(true)
158+
expect(connectButton.text()).toContain('Connect')
159+
expect(wrapper.text()).not.toContain('Config not found')
160+
})
161+
99162
it('disconnect uses server_name alias when OpenCode status is adopted', async () => {
100163
;(api.getConnectStatus as any).mockResolvedValue({
101164
success: true,
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { isBlankInstructions, type SettingField } from '../../src/views/settings/fields'
3+
4+
// MCP-2484: instructions textarea prefill + reset-to-default
5+
6+
// Unit: SettingField interface accepts resetDefault (compile-time test via import)
7+
describe('SettingField.resetDefault (MCP-2484)', () => {
8+
it('allows a resetDefault property on SettingField without TS error', () => {
9+
const f: SettingField = {
10+
key: 'instructions',
11+
label: 'Server instructions',
12+
control: 'textarea',
13+
optional: true,
14+
resetDefault: 'built-in default text',
15+
}
16+
expect(f.resetDefault).toBe('built-in default text')
17+
})
18+
19+
it('resetDefault is optional — omitting it does not cause a TS error', () => {
20+
const f: SettingField = {
21+
key: 'instructions',
22+
label: 'Server instructions',
23+
control: 'textarea',
24+
optional: true,
25+
}
26+
expect(f.resetDefault).toBeUndefined()
27+
})
28+
})
29+
30+
// Unit: isBlankInstructions helper
31+
describe('isBlankInstructions (MCP-2484)', () => {
32+
it('treats unset/empty/whitespace as blank (eligible for prefill)', () => {
33+
expect(isBlankInstructions(undefined)).toBe(true)
34+
expect(isBlankInstructions(null)).toBe(true)
35+
expect(isBlankInstructions('')).toBe(true)
36+
expect(isBlankInstructions(' \n ')).toBe(true)
37+
})
38+
39+
it('treats a real saved value as non-blank (never overwrite)', () => {
40+
expect(isBlankInstructions('my custom instructions')).toBe(false)
41+
})
42+
})
43+
44+
// Unit: maybePrefillInstructions logic (isolated, not mounting the full component)
45+
describe('maybePrefillInstructions logic (MCP-2484)', () => {
46+
function prefill(working: Record<string, any>, defaultVal: string, isLoaded: boolean) {
47+
if (!defaultVal) return
48+
if (!isLoaded) return
49+
if (isBlankInstructions(working.instructions)) {
50+
working.instructions = defaultVal
51+
}
52+
}
53+
54+
it('prefills instructions when config is empty and default is available', () => {
55+
const w: Record<string, any> = { instructions: '' }
56+
prefill(w, 'built-in default', true)
57+
expect(w.instructions).toBe('built-in default')
58+
})
59+
60+
it('prefills when instructions is null', () => {
61+
const w: Record<string, any> = { instructions: null }
62+
prefill(w, 'built-in default', true)
63+
expect(w.instructions).toBe('built-in default')
64+
})
65+
66+
it('prefills when instructions is undefined', () => {
67+
const w: Record<string, any> = {}
68+
prefill(w, 'built-in default', true)
69+
expect(w.instructions).toBe('built-in default')
70+
})
71+
72+
it('prefills when instructions is whitespace-only', () => {
73+
const w: Record<string, any> = { instructions: ' \n ' }
74+
prefill(w, 'built-in default', true)
75+
expect(w.instructions).toBe('built-in default')
76+
})
77+
78+
it('does NOT overwrite a saved custom instruction', () => {
79+
const w: Record<string, any> = { instructions: 'my custom instructions' }
80+
prefill(w, 'built-in default', true)
81+
expect(w.instructions).toBe('my custom instructions')
82+
})
83+
84+
it('does NOT prefill when config is not yet loaded', () => {
85+
const w: Record<string, any> = { instructions: '' }
86+
prefill(w, 'built-in default', false)
87+
expect(w.instructions).toBe('')
88+
})
89+
90+
it('does NOT prefill when defaultInstructions is empty (API not yet resolved)', () => {
91+
const w: Record<string, any> = { instructions: '' }
92+
prefill(w, '', true)
93+
expect(w.instructions).toBe('')
94+
})
95+
})

0 commit comments

Comments
 (0)