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
12 changes: 7 additions & 5 deletions docs/features/routing-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ MCPProxy supports three routing modes that control how upstream MCP tools are ex

## Overview

| Mode | Default MCP Endpoint | Tool Exposure | Best For |
|------|---------------------|---------------|----------|
| `retrieve_tools` | `/mcp` | BM25 search via `retrieve_tools` + `call_tool_read/write/destructive` | Large tool sets (50+ tools), token-sensitive workloads |
| `direct` | `/mcp` | All tools exposed as `serverName__toolName` | Small tool sets, simple setups, maximum compatibility |
| `code_execution` | `/mcp` | `code_execution` + `retrieve_tools` for discovery | Multi-step orchestration, reduced round-trips |
| Mode | Tool Exposure | Best For |
|------|---------------|----------|
| `retrieve_tools` | BM25 search via `retrieve_tools` + `call_tool_read/write/destructive` | Large tool sets (50+ tools), token-sensitive workloads |
| `direct` | All tools exposed as `serverName__toolName` | Small tool sets, simple setups, maximum compatibility |
| `code_execution` | `code_execution` + `retrieve_tools` for discovery | Multi-step orchestration, reduced round-trips |

> The configured mode is served on the default `/mcp` endpoint. Each mode also has a dedicated endpoint (`/mcp/call`, `/mcp/all`, `/mcp/code`) — see [Dedicated Endpoints](#dedicated-endpoints) below.

## Configuration

Expand Down
38 changes: 38 additions & 0 deletions docs/features/settings-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Settings Page (Web UI)

The Web UI **Configuration** page (`/ui/settings`) presents mcpproxy's config as
friendly, prioritized form sections instead of raw JSON:

- **Security & Access** — API key (masked, show/regenerate), require MCP auth,
quarantine, global Docker isolation, code execution, read-only mode,
sensitive-data detection, reveal secret headers, listen address.
- **General** — routing mode, tool limits, response limit, call timeout, log
level, telemetry, prompts.
- **Advanced** — collapsible accordions per subsystem (code execution, Docker
isolation, sensitive-data detection, output validation, output sanitisation,
activity retention, logging, TLS, …).
- **Raw JSON** — the full Monaco editor, kept as an escape hatch.
- **Teams** — server edition only.

## How saving works

Each section saves **only the fields you changed** via `PATCH /api/v1/config`, a
partial deep-merge that routes through the normal validate → persist → hot-reload
pipeline. Because the merge starts from the live config and overlays just your
changes, unrelated values and masked secrets (API key, secret headers) are never
overwritten.

Fields that need a restart (`listen`, `data_dir`, `api_key`, `tls.*`) show a
**restart** badge; sensitive changes (reveal secret headers, disabling
quarantine/management, binding to a non-loopback address) require an explicit
confirmation before they apply.

```bash
# Equivalent API call — change one field, everything else preserved
curl -X PATCH -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"quarantine_enabled": false}' http://127.0.0.1:8080/api/v1/config
```

Complex lists/maps (Docker image map, custom detection patterns, environment
vars) and `mcpServers` / `registries` are managed on their own pages or the Raw
JSON tab. See the [configuration reference](../configuration/config-file.md) for the full option list.
231 changes: 231 additions & 0 deletions frontend/src/components/settings/SettingField.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
<template>
<div
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 py-3 px-2 -mx-2 border-b border-base-200 last:border-0 rounded transition-colors"
:class="dirty ? 'bg-warning/5 border-l-2 border-l-warning' : 'border-l-2 border-l-transparent'"
:data-test="`setting-row-${field.key}`"
>
<!-- Label + help + badges -->
<div class="sm:max-w-md">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium">{{ field.label }}</span>
<span v-if="dirty" class="badge badge-warning badge-xs" title="Unsaved change">●</span>
<a
v-if="docsHref"
:href="docsHref"
target="_blank"
rel="noopener noreferrer"
class="link link-primary text-xs font-normal"
:data-test="`setting-docs-${field.key}`"
title="Open documentation"
@click.stop
>docs ↗</a>
<span v-if="field.restart" class="badge badge-warning badge-xs gap-1" title="Requires restart">
restart
</span>
<span v-if="field.danger && field.danger.tone !== 'info'" class="badge badge-error badge-xs" title="Sensitive change">
sensitive
</span>
</div>
<p v-if="field.help" class="text-xs text-base-content/60 mt-0.5">{{ field.help }}</p>
</div>

<!-- Control -->
<div class="shrink-0">
<!-- toggle -->
<input
v-if="field.control === 'toggle'"
type="checkbox"
class="toggle toggle-primary"
:checked="!!modelValue"
:data-test="`setting-toggle-${field.key}`"
@change="emitVal(($event.target as HTMLInputElement).checked)"
/>

<!-- select -->
<select
v-else-if="field.control === 'select'"
class="select select-bordered select-sm min-w-[12rem]"
:value="modelValue ?? ''"
:data-test="`setting-select-${field.key}`"
@change="emitVal(($event.target as HTMLSelectElement).value)"
>
<option v-for="opt in field.options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>

<!-- number -->
<div v-else-if="field.control === 'number'" class="flex flex-col items-end">
<input
type="number"
class="input input-bordered input-sm w-32"
:class="{ 'input-error': validationError }"
:value="modelValue"
:min="field.min"
:max="field.max"
:step="field.step"
:data-test="`setting-number-${field.key}`"
@input="onNumber(($event.target as HTMLInputElement).value)"
/>
<span v-if="validationError" class="text-error text-xs mt-1" :data-test="`setting-error-${field.key}`">{{ validationError }}</span>
</div>

<!-- secret -->
<template v-else-if="field.control === 'secret'">
<div class="join">
<input
:type="showSecret ? 'text' : 'password'"
class="input input-bordered input-sm join-item w-56 font-mono"
:value="modelValue ?? ''"
:placeholder="field.placeholder"
:data-test="`setting-secret-${field.key}`"
@input="emitVal(($event.target as HTMLInputElement).value)"
/>
<button type="button" class="btn btn-sm join-item" :title="showSecret ? 'Hide' : 'Show'" @click="showSecret = !showSecret">
{{ showSecret ? '🙈' : '👁' }}
</button>
<button
type="button"
class="btn btn-sm join-item"
:class="{ 'btn-success': copied }"
:data-test="`setting-copy-${field.key}`"
:title="copied ? 'Copied!' : 'Copy to clipboard'"
:disabled="!modelValue"
@click="copy"
>
{{ copied ? '✓' : '📋' }}
</button>
<button
type="button"
class="btn btn-sm join-item"
:data-test="`setting-regenerate-${field.key}`"
title="Generate a new random key"
@click="regenEl?.showModal()"
>
↻
</button>
</div>
<dialog ref="regenEl" class="modal" :data-test="`setting-regenerate-confirm-${field.key}`">
<div class="modal-box">
<h3 class="font-bold text-lg">Generate a new {{ field.label }}?</h3>
<p class="text-sm mt-2">
A new random value will replace the current one in the field. Nothing changes until you click
<b>Save changes</b> — after saving you'll need to update connected clients and restart.
</p>
<div class="modal-action">
<button class="btn btn-sm" @click="regenEl?.close()" :data-test="`setting-regenerate-cancel-${field.key}`">Cancel</button>
<button class="btn btn-sm btn-primary" @click="confirmRegenerate" :data-test="`setting-regenerate-proceed-${field.key}`">
Generate
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
</template>

<!-- text / duration -->
<div v-else-if="field.control === 'text' || field.control === 'duration'" class="flex flex-col items-end">
<input
type="text"
class="input input-bordered input-sm w-56 font-mono"
:class="{ 'input-error': validationError }"
:value="modelValue ?? ''"
:placeholder="field.placeholder"
:data-test="`setting-text-${field.key}`"
@input="emitVal(($event.target as HTMLInputElement).value)"
/>
<span v-if="validationError" class="text-error text-xs mt-1" :data-test="`setting-error-${field.key}`">{{ validationError }}</span>
</div>

<!-- multiselect (checkbox group) -->
<div v-else-if="field.control === 'multiselect'" class="flex flex-wrap gap-3 justify-end max-w-xs">
<label v-for="opt in field.options" :key="opt.value" class="label cursor-pointer gap-1 p-0">
<input
type="checkbox"
class="checkbox checkbox-xs"
:checked="Array.isArray(modelValue) && modelValue.includes(opt.value)"
:data-test="`setting-multi-${field.key}-${opt.value}`"
@change="toggleMulti(opt.value, ($event.target as HTMLInputElement).checked)"
/>
<span class="text-xs">{{ opt.label }}</span>
</label>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { docsUrl, validateField, type SettingField } from '@/views/settings/fields'

const props = defineProps<{ field: SettingField; modelValue: any; dirty?: boolean }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: any): void }>()

const showSecret = ref(false)
const copied = ref(false)
const regenEl = ref<HTMLDialogElement | null>(null)
const docsHref = computed(() => docsUrl(props.field.docs))

async function copy() {
const val = props.modelValue
if (!val) return
try {
await navigator.clipboard.writeText(String(val))
} catch {
// clipboard API unavailable (e.g. non-secure context) — fall back
const ta = document.createElement('textarea')
ta.value = String(val)
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy')
} catch {
/* ignore */
}
document.body.removeChild(ta)
}
copied.value = true
setTimeout(() => {
copied.value = false
}, 1500)
}

function confirmRegenerate() {
regenEl.value?.close()
regenerate()
}

// Only surface validation errors for fields the user has actually edited, so a
// pre-existing value (e.g. a short legacy API key) never alarms or blocks
// saving unrelated fields.
const validationError = computed(() => (props.dirty ? validateField(props.field, props.modelValue) : null))

function emitVal(v: any) {
emit('update:modelValue', v)
}

function onNumber(raw: string) {
if (raw === '') {
emitVal('')
return
}
emitVal(Number(raw))
}

function toggleMulti(value: string, checked: boolean) {
const cur = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const idx = cur.indexOf(value)
if (checked && idx === -1) cur.push(value)
if (!checked && idx !== -1) cur.splice(idx, 1)
emitVal(cur)
}

function regenerate() {
// 32-byte random hex key — generated client-side; user must save + restart.
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
showSecret.value = true
emitVal(hex)
}

defineExpose({ validationError })
</script>
Loading
Loading