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
257 changes: 154 additions & 103 deletions frontend/src/components/ModeSwitcher.vue

Large diffs are not rendered by default.

67 changes: 47 additions & 20 deletions frontend/src/components/SerializationAxis.vue
Original file line number Diff line number Diff line change
@@ -1,47 +1,70 @@
<template>
<!--
One serialization axis (Spec 085 `tool_response_mode` or Spec 102
`direct_tool_response_mode`) rendered as a two-option choice.
`direct_tool_response_mode`) as a two-option choice.

Both axes are ALWAYS in effect somewhere — the dedicated endpoints never go
away — so the surface is named rather than the axis greyed out. That is the
difference an operator needs to make the call: "Direct listings" still
governs /mcp/all while /mcp serves Retrieve.
difference an operator needs: "Direct listings" still governs /mcp/all while
/mcp serves Retrieve.

One visible line per option; the full explanation is the hover hint, so the
panel never has to scroll.
-->
<div class="px-2 pb-2" :data-test="`serialization-axis-${axisId}`">
<div
class="px-2 pb-1"
role="radiogroup"
:aria-label="title"
:data-test="`serialization-axis-${axisId}`"
>
<div class="px-1 flex items-baseline justify-between gap-2">
<span class="text-xs font-medium">{{ title }}</span>
<code class="text-[0.65rem] font-mono text-base-content/50" :data-test="`serialization-surface-${axisId}`">{{ surface }}</code>
<span class="flex items-baseline gap-2">
<code
class="text-[0.65rem] font-mono text-base-content/60"
:data-test="`serialization-surface-${axisId}`"
>{{ surface }}</code>
<a
v-if="docHref"
:href="docHref"
target="_blank"
rel="noopener"
class="text-xs link link-hover text-base-content/60"
:data-test="`serialization-doc-${axisId}`"
>Docs ↗</a>
</span>
</div>
<p
v-if="note"
class="px-1 mt-1 text-xs text-base-content/60 leading-relaxed"
class="px-1 text-xs text-base-content/70 leading-snug cursor-help"
:title="noteHint"
:data-test="`serialization-note-${axisId}`"
>{{ note }}</p>
<!-- role=radio + aria-checked, because the selection was conveyed only by a
background tint and an unlabelled tick — neither reaches assistive tech.
aria-busy rather than `disabled` while a write is in flight: disabling
the focused button drops focus to <body> mid-interaction. -->
<button
v-for="o in options"
:key="o.value"
type="button"
role="radio"
:aria-checked="o.value === selected"
:data-test="`serialization-option-${axisId}-${o.value}`"
class="w-full text-left px-2 py-2 mt-1 rounded hover:bg-base-200 flex items-start justify-between"
:title="o.detail"
class="w-full text-left px-2 py-1.5 mt-1 rounded hover:bg-base-200 flex items-center justify-between gap-2"
:class="{ 'bg-base-200': o.value === selected }"
:disabled="busy"
:aria-busy="busy"
@click="emit('select', o.value)"
>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{{ o.label }}</span>
<span
v-if="o.value === selected"
class="badge badge-xs badge-primary"
:data-test="`serialization-active-${axisId}`"
>active</span>
</div>
<div class="text-xs text-base-content/60 mt-0.5 leading-relaxed">{{ o.description }}</div>
</div>
<span class="min-w-0">
<span class="text-sm font-medium">{{ o.label }}</span>
<span class="text-xs text-base-content/70"> — {{ o.summary }}</span>
</span>
<svg
v-if="o.value === selected"
class="w-4 h-4 text-success shrink-0 ml-2 mt-0.5"
class="w-4 h-4 text-success shrink-0"
:data-test="`serialization-active-${axisId}`"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
Expand All @@ -66,6 +89,10 @@ defineProps<{
/** Caveat shown under the heading when the axis does not govern what the
surface line alone would imply. */
note?: string
/** The reason behind `note`, as a hover hint. */
noteHint?: string
/** Docs page for THIS axis — the two axes are documented separately. */
docHref?: string
}>()

const emit = defineEmits<{ (e: 'select', value: string): void }>()
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/stores/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ export const useSystemStore = defineStore('system', () => {
// that predates the fields.
const toolResponseMode = computed(() => routing.value?.tool_response_mode ?? 'full')
const directToolResponseMode = computed(() => routing.value?.direct_tool_response_mode ?? 'full')
// Spec 097/code-exec gate. The code-execution SURFACE has no tool-calling
// path other than the code_execution tool, which refuses while this is off,
// so the mode switcher warns before an operator restarts into it. Defaults to
// true against a daemon that predates the field: a missing field must not
// render a warning we cannot substantiate.
const codeExecutionEnabled = computed(() => routing.value?.code_execution_enabled ?? true)

// Actions
function connectEventSource() {
Expand Down Expand Up @@ -670,6 +676,7 @@ export const useSystemStore = defineStore('system', () => {
routingRestartRequired,
toolResponseMode,
directToolResponseMode,
codeExecutionEnabled,
sidebarCollapsed,

// Actions
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 @@ -438,6 +438,12 @@ export interface RoutingInfo {
pending_routing_mode?: string
/** True when pending_routing_mode is set — a restart is needed to apply it. */
restart_required?: boolean
/**
* Whether the code_execution tool is enabled. The code-execution surface has
* no other tool-calling path, so this gates whether that routing mode can
* work at all. Absent on daemons that predate the field.
*/
code_execution_enabled?: boolean
}

// Dashboard stats
Expand Down
118 changes: 72 additions & 46 deletions frontend/src/utils/routingMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@
* Routing-mode and serialization-mode vocabulary for the header mode switcher.
*
* `Mode: Retrieve` sat in the header with nothing to explain it (audit F31) —
* it is the single most consequential setting on the page (it decides what an
* agent sees when it connects) and it was rendered as a bare word, with a
* `cursor-help` that produced a question mark and no hint. Label, explanation
* and trade-off live together here so the badge, the switcher and any future
* surface cannot drift apart.
* the single most consequential setting on the page, rendered as a bare word
* with a `cursor-help` that produced a question mark and no hint.
*
* Two DIFFERENT axes are described in this file and must not be conflated:
* Each entry carries THREE lengths of the same fact, because a dropdown that
* must not scroll cannot afford one long one:
* - `label` — the chip.
* - `summary` — one short line, always visible. What it does, in ~8 words.
* - `detail` — the full explanation and its trade-off, shown on hover
* (`title`) and linked to the docs. Never rendered inline.
*
* Two DIFFERENT axes are described here and must not be conflated:
*
* - `routing_mode` picks the tool SURFACE served on /mcp (retrieve / direct /
* code execution). It binds to an http.ServeMux pattern at startup, so
Expand All @@ -17,47 +21,59 @@
* pick how each entry on a surface is SERIALIZED. Both hot-reload.
*/

/** Published docs, not repo paths — these are rendered as links. */
export const ROUTING_MODES_DOC = 'https://docs.mcpproxy.app/features/routing-modes'
/** The retrieve axis (Spec 085) — a different page from the direct one. */
export const TOOL_RESPONSE_DOC = 'https://docs.mcpproxy.app/features/search-discovery'
/** The direct axis (Spec 102). */
export const DIRECT_TOOL_RESPONSE_DOC =
'https://docs.mcpproxy.app/features/schema-deferred-direct-mode'

export interface RoutingModeMeta {
/** Config value. */
mode: string
/** Compact label rendered in the header badge. */
/** Compact label rendered in the header badge and the option row. */
label: string
/** One-sentence explanation, shown as the badge's tooltip. */
description: string
/** What choosing this mode costs or buys — the informed-decision half. */
tradeoff: string
/** One short line, always visible. */
summary: string
/** Full explanation + trade-off. Hover hint and tooltip only. */
detail: string
/** Dedicated endpoint that always serves this mode, restart or not. */
endpoint: string
/**
* Set when the mode needs something else switched on first. Rendered INLINE
* when unmet — a prerequisite that only appears on hover is one the operator
* discovers after restarting into a surface that cannot call anything.
*/
prerequisiteNote?: string
}

const RETRIEVE: RoutingModeMeta = {
mode: 'retrieve_tools',
label: 'Retrieve',
description:
'Retrieve mode: agents search for the tools they need with retrieve_tools, then call them — only matching tools enter the agent’s context.',
tradeoff:
'Smallest context: an agent sees a handful of meta-tools instead of your whole catalog, but has to search before it can call anything.',
summary: 'Search first — a few meta-tools.',
detail:
'Agents search with retrieve_tools, then call what they found, so only matching tools enter the context. An agent sees a handful of meta-tools instead of your whole catalog, but has to search before it can call anything.',
endpoint: '/mcp/call',
}

const DIRECT: RoutingModeMeta = {
mode: 'direct',
label: 'Direct',
description:
'Direct mode: every enabled tool from every server is listed to the agent up front — no search step, but the full tool list costs context.',
tradeoff:
'Nothing is hidden and there is no search step, but the whole catalog sits in the prompt from the first message — roughly 190 tokens per tool with schemas.',
summary: 'All tools up front — ~190 tokens each.',
detail:
'Every tool visible to the session is listed to the agent on connect — quarantined, disabled and out-of-profile servers are still filtered out. No search step, but the whole catalog sits in the prompt from the first message, roughly 190 tokens per tool with full schemas.',
endpoint: '/mcp/all',
}

const CODE_EXECUTION: RoutingModeMeta = {
mode: 'code_execution',
label: 'Code Exec',
description:
'Code execution mode: agents orchestrate several upstream tools from one sandboxed JavaScript call, discovering them with retrieve_tools.',
tradeoff:
'Fewest round trips for multi-step work — one sandboxed JavaScript call can chain several tools. Needs code execution enabled in Settings.',
summary: 'Search first, then chain tools in JS.',
detail:
'Agents discover tools with retrieve_tools and orchestrate several of them from one sandboxed JavaScript call — the fewest round trips for multi-step work. Requires code execution to be enabled in Settings; without it this surface can find tools but call none of them.',
endpoint: '/mcp/code',
prerequisiteNote: 'Enable code execution in Settings first.',
}

const ROUTING_MODES: Record<string, RoutingModeMeta> = {
Expand All @@ -75,23 +91,27 @@ export function routingModeMeta(mode: string | undefined | null): RoutingModeMet
return ROUTING_MODES[mode] ?? RETRIEVE
}

/** One line under the surface list. The reason lives in the hover hint. */
export const ROUTING_RESTART_NOTE = 'Changing /mcp needs a restart — its endpoint never does.'

/**
* Why a routing-mode switch cannot take effect until the core restarts, and the
* way around it. /mcp is bound to ONE mcp-go server instance at startup
* Why. /mcp is bound to ONE mcp-go server instance at startup
* (internal/server/server.go → GetMCPServerForMode) on an http.ServeMux, which
* cannot re-register a pattern; the dedicated routes are each permanently bound
* to their own mode by design (Spec 031) and are unaffected.
*/
export const ROUTING_RESTART_NOTE =
'Changing the surface on /mcp needs a restart — /mcp binds its mode when mcpproxy starts. Nothing to restart if your client can point at a dedicated endpoint instead: each one always serves its own mode.'
export const ROUTING_RESTART_HINT =
'/mcp binds its mode when mcpproxy starts, so switching it takes effect on the next start. Nothing to restart if your client can point at a dedicated endpoint instead: each one always serves its own mode.'

export interface SerializationModeMeta {
/** Config value. */
value: string
/** Label for the option row. */
label: string
/** What the agent actually receives, and what it costs. */
description: string
/** One short line, always visible. */
summary: string
/** What the agent actually receives, and what it costs. Hover hint only. */
detail: string
}

/**
Expand All @@ -102,36 +122,40 @@ export const TOOL_RESPONSE_MODES: SerializationModeMeta[] = [
{
value: 'full',
label: 'Full schemas',
description:
summary: 'Complete input schema per result.',
detail:
'Every search result carries its complete input schema — nothing extra for the agent to fetch.',
},
{
value: 'compact',
label: 'Signatures, schema on demand',
description:
'A one-line signature plus a first-sentence description; the agent pulls a full schema with describe_tool when it needs one. Same tools are found either way.',
label: 'Signatures',
summary: 'Signature now, schema on demand.',
detail:
'A one-line signature plus a first-sentence description; the agent pulls a full schema with describe_tool when it needs one. Saves tokens, and never changes which tools are found.',
},
]

/**
* `direct_tool_response_mode` (Spec 102) — how each entry of a direct-surface
* tools/list is rendered. Same tools, same names, same annotations either way.
* The savings quoted here are the measured ones from the shipped feature, not
* the original projection: SC-001 was restated because names and descriptions,
* not schemas, dominate these corpora.
* The savings quoted are the measured ones from the shipped feature, not the
* original projection: SC-001 was restated because names and descriptions, not
* schemas, dominate these corpora.
*/
export const DIRECT_TOOL_RESPONSE_MODES: SerializationModeMeta[] = [
{
value: 'full',
label: 'Full schemas',
description:
summary: 'Complete input schema per tool.',
detail:
'Every tool is listed with its complete input schema. Keep this for clients that build forms from the advertised schema.',
},
{
value: 'deferred',
label: 'Signatures, schema on demand',
description:
'Same tools and names without the schemas: measured 29.7% smaller on a 45-tool listing, 34.8% on 527 tools. Tools marked ~ cost one describe_tool call; a wrong guess is rejected before it reaches the server, with the schema attached.',
label: 'Signatures',
summary: '~30% smaller listing, schema on demand.',
detail:
'Same tools and names without the schemas: measured 29.7% smaller on a 45-tool listing and 34.8% on 527 tools. Tools marked ~ cost one describe_tool call; a wrong guess is rejected before it reaches the server, with the schema attached.',
},
]

Expand All @@ -154,13 +178,15 @@ export function serializationModeMeta(
* cannot call. Claiming /mcp there would be a lie the operator could act on.
*/
export function toolResponseSurface(routingMode: string | undefined | null): string {
return routingMode === 'retrieve_tools' || !routingMode ? '/mcp and /mcp/call' : '/mcp/call'
return routingMode === 'retrieve_tools' || !routingMode ? '/mcp · /mcp/call' : '/mcp/call'
}

/** Why the retrieve axis does not reach /mcp under code execution. */
export const TOOL_RESPONSE_CODE_EXEC_NOTE =
'Code-execution mode always sends full schemas on /mcp — describe_tool is not exposed there, so there is nothing to fetch a deferred schema with. This setting still governs /mcp/call.'

export function directToolResponseSurface(routingMode: string | undefined | null): string {
return routingMode === 'direct' ? '/mcp and /mcp/all' : '/mcp/all'
return routingMode === 'direct' ? '/mcp · /mcp/all' : '/mcp/all'
}

/** Why the retrieve axis does not reach /mcp under code execution. */
export const TOOL_RESPONSE_CODE_EXEC_NOTE = 'Code Exec always sends full schemas on /mcp.'

export const TOOL_RESPONSE_CODE_EXEC_HINT =
'describe_tool is not exposed on the code-execution surface, so there would be nothing to fetch a deferred schema with. This setting still governs /mcp/call.'
Loading
Loading