-
How much of each tool is written out
-
- Applies immediately — no restart, and connected clients are told to refetch their tool list.
+
-
@@ -230,6 +252,7 @@ import { ref, computed, onBeforeUnmount, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useSystemStore } from '@/stores/system'
import SerializationAxis from './SerializationAxis.vue'
+import type { RoutingModeMeta } from '@/utils/routingMode'
import {
ROUTING_MODE_LIST,
ROUTING_RESTART_NOTE,
@@ -240,6 +263,11 @@ import {
toolResponseSurface,
directToolResponseSurface,
TOOL_RESPONSE_CODE_EXEC_NOTE,
+ TOOL_RESPONSE_CODE_EXEC_HINT,
+ ROUTING_RESTART_HINT,
+ ROUTING_MODES_DOC,
+ TOOL_RESPONSE_DOC,
+ DIRECT_TOOL_RESPONSE_DOC,
} from '@/utils/routingMode'
const systemStore = useSystemStore()
@@ -267,23 +295,37 @@ const showPendingNotice = computed(
* every endpoint's serialization.
*/
const serializationBadge = computed(() => {
+ // Code execution PINS its /mcp surface to full schemas (Spec 085 FR-011), so
+ // a "compact" badge there would advertise a serialization the surface does
+ // not use. Say nothing rather than something false.
+ if (systemStore.routingMode === 'code_execution') return ''
const isDirect = systemStore.routingMode === 'direct'
const value = isDirect ? systemStore.directToolResponseMode : systemStore.toolResponseMode
if (!value || value === 'full') return ''
- return isDirect ? 'deferred' : 'compact'
+ // The panel's word for it, not the raw config value: an operator who opens
+ // the panel to find "compact" will only see "Signatures".
+ return serializationModeMeta(
+ isDirect ? DIRECT_TOOL_RESPONSE_MODES : TOOL_RESPONSE_MODES,
+ value
+ ).label
+})
+
+/** The pending notice is one line; the rest of the story is its hover hint. */
+const pendingHint = computed(() => {
+ const prereq = unmetPrerequisite(pendingMeta.value)
+ if (prereq) {
+ return `/mcp is still serving ${activeMeta.value.label}. ${pendingMeta.value.label} is saved and applies the next time mcpproxy starts, but that surface can only call tools once code execution is enabled in Settings.`
+ }
+ return `/mcp is still serving ${activeMeta.value.label}. ${pendingMeta.value.label} is saved and applies the next time mcpproxy starts; until then ${pendingMeta.value.endpoint} already serves it.`
})
/**
- * Both tab chips report what is SERVING, never what is pending: two chips
- * disagreeing inside one panel (Surface saying "Direct" while the header badge
- * says "Retrieve") reads as a bug. The pending choice is carried by the notice
- * at the top of the panel and by the per-option "after restart" badge, which
- * say so in words.
- *
- * The Schema-detail chip names the axis the CURRENT routing mode puts on /mcp —
- * the one an operator is asking about when they look at the header.
+ * The Schema-detail chip names what the CURRENT surface actually sends. Under
+ * code execution /mcp is pinned to full schemas whatever the config says
+ * (Spec 085 FR-011), so the chip must not repeat the config there.
*/
const detailTabValue = computed(() => {
+ if (systemStore.routingMode === 'code_execution') return 'Full schemas'
const isDirect = systemStore.routingMode === 'direct'
const value = isDirect ? systemStore.directToolResponseMode : systemStore.toolResponseMode
return serializationModeMeta(
@@ -297,9 +339,18 @@ const codeExecNote = computed(() =>
systemStore.routingMode === 'code_execution' ? TOOL_RESPONSE_CODE_EXEC_NOTE : undefined
)
+/**
+ * The prerequisite text for a mode whose prerequisite is NOT met, else "".
+ * Only code execution has one today, and its flag defaults to off.
+ */
+function unmetPrerequisite(meta: RoutingModeMeta): string {
+ if (meta.mode !== 'code_execution' || systemStore.codeExecutionEnabled) return ''
+ return meta.prerequisiteNote ?? ''
+}
+
const buttonTitle = computed(() => {
- const parts = [activeMeta.value.description]
- if (systemStore.routingRestartRequired) {
+ const parts = [`${activeMeta.value.label} mode — ${activeMeta.value.detail}`]
+ if (showPendingNotice.value) {
parts.push(`Restart pending: ${pendingMeta.value.label} applies after mcpproxy restarts.`)
}
return parts.join(' ')
diff --git a/frontend/src/components/SerializationAxis.vue b/frontend/src/components/SerializationAxis.vue
index 0394a4735..cbe9c7999 100644
--- a/frontend/src/components/SerializationAxis.vue
+++ b/frontend/src/components/SerializationAxis.vue
@@ -1,47 +1,70 @@
-
+
{{ title }}
-
{{ surface }}
+
+ {{ surface }}
+ Docs ↗
+
{{ note }}
+
-
-
- {{ o.label }}
- active
-
-
{{ o.description }}
-
+
+ {{ o.label }}
+ — {{ o.summary }}
+
()
const emit = defineEmits<{ (e: 'select', value: string): void }>()
diff --git a/frontend/src/stores/system.ts b/frontend/src/stores/system.ts
index 286683140..9d47a54c6 100644
--- a/frontend/src/stores/system.ts
+++ b/frontend/src/stores/system.ts
@@ -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() {
@@ -670,6 +676,7 @@ export const useSystemStore = defineStore('system', () => {
routingRestartRequired,
toolResponseMode,
directToolResponseMode,
+ codeExecutionEnabled,
sidebarCollapsed,
// Actions
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 0c8876c19..ee1b4f013 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -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
diff --git a/frontend/src/utils/routingMode.ts b/frontend/src/utils/routingMode.ts
index 00aa5bd16..245099fa3 100644
--- a/frontend/src/utils/routingMode.ts
+++ b/frontend/src/utils/routingMode.ts
@@ -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
@@ -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 = {
@@ -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
}
/**
@@ -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.',
},
]
@@ -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.'
diff --git a/frontend/tests/unit/mode-switcher.spec.ts b/frontend/tests/unit/mode-switcher.spec.ts
index b2579fc9b..bfaad6553 100644
--- a/frontend/tests/unit/mode-switcher.spec.ts
+++ b/frontend/tests/unit/mode-switcher.spec.ts
@@ -24,6 +24,7 @@ type RoutingOverrides = Partial<{
direct_tool_response_mode: string
pending_routing_mode: string
restart_required: boolean
+ code_execution_enabled: boolean
}>
// Mirrors the /api/v1/routing payload, including the resolution the handler
@@ -45,6 +46,7 @@ function routingPayload(o: RoutingOverrides = {}) {
direct_tool_response_mode: 'full',
pending_routing_mode: '',
restart_required: false,
+ code_execution_enabled: true,
...o,
}
}
@@ -91,9 +93,13 @@ describe('ModeSwitcher', () => {
expect(wrapper.find('[data-test="mode-restart-badge-direct"]').text()).toBe('needs restart')
expect(wrapper.find('[data-test="mode-restart-badge-code_execution"]').exists()).toBe(true)
- const direct = wrapper.find('[data-test="mode-option-direct"]').text()
- expect(direct).toContain('/mcp/all')
- expect(direct.length).toBeGreaterThan(120) // description + trade-off, not a bare word
+ // Visible: one short line plus the endpoint chip. The full explanation and
+ // its trade-off are the hover hint — the panel must not scroll, so the long
+ // form cannot be inline. This asserts it is still REACHABLE, not dropped.
+ const direct = wrapper.find('[data-test="mode-option-direct"]')
+ expect(direct.text()).toContain('/mcp/all')
+ expect(direct.text().length).toBeLessThan(110)
+ expect(direct.attributes('title')).toContain('190 tokens per tool')
// The way to get Direct WITHOUT a restart has to be on screen, or the only
// visible path to it is a restart the operator may not want.
@@ -177,18 +183,20 @@ describe('ModeSwitcher', () => {
it('quantifies the deferred trade-off instead of naming it', async () => {
const wrapper = await mountOpen()
- const deferred = wrapper
- .find('[data-test="serialization-option-direct-tool-response-deferred"]')
- .text()
- // Measured numbers from Spec 102 (SC-001 as restated), and the cost.
- expect(deferred).toMatch(/29\.7%|34\.8%/)
- expect(deferred).toContain('describe_tool')
+ const deferred = wrapper.find(
+ '[data-test="serialization-option-direct-tool-response-deferred"]'
+ )
+ // The visible line quantifies it roughly; the hover hint carries the
+ // measured numbers from Spec 102 (SC-001 as restated) and the cost.
+ expect(deferred.text()).toContain('%')
+ expect(deferred.attributes('title')).toMatch(/29\.7%|34\.8%/)
+ expect(deferred.attributes('title')).toContain('describe_tool')
})
it('names the endpoints each axis governs, which depends on the routing mode', async () => {
const retrieve = await mountOpen()
expect(retrieve.find('[data-test="serialization-surface-tool-response"]').text()).toContain(
- '/mcp and /mcp/call'
+ '/mcp · /mcp/call'
)
// Direct listings still govern /mcp/all while /mcp serves Retrieve — the
// axis is never inert, so it is never greyed out.
@@ -199,18 +207,30 @@ describe('ModeSwitcher', () => {
const direct = await mountOpen({ routing_mode: 'direct' })
expect(
direct.find('[data-test="serialization-surface-direct-tool-response"]').text()
- ).toContain('/mcp and /mcp/all')
+ ).toContain('/mcp · /mcp/all')
})
it('surfaces a non-default serialization on the collapsed badge', async () => {
+ // The panel's word for it, not the raw config value: an operator who opens
+ // the panel after reading "compact" would find no such word in it.
const compact = await mountOpen({ tool_response_mode: 'compact' })
- expect(compact.find('[data-test="mode-switcher-serialization-badge"]').text()).toBe('compact')
+ expect(compact.find('[data-test="mode-switcher-serialization-badge"]').text()).toBe('Signatures')
const deferred = await mountOpen({
routing_mode: 'direct',
direct_tool_response_mode: 'deferred',
})
- expect(deferred.find('[data-test="mode-switcher-serialization-badge"]').text()).toBe('deferred')
+ expect(deferred.find('[data-test="mode-switcher-serialization-badge"]').text()).toBe(
+ 'Signatures'
+ )
+
+ // Code execution PINS /mcp to full schemas, so a badge there would advertise
+ // a serialization that surface does not use.
+ const codeExec = await mountOpen({
+ routing_mode: 'code_execution',
+ tool_response_mode: 'compact',
+ })
+ expect(codeExec.find('[data-test="mode-switcher-serialization-badge"]').exists()).toBe(false)
// …and stays quiet when both axes are at their default.
const plain = await mountOpen()
@@ -245,9 +265,9 @@ describe('ModeSwitcher', () => {
expect(codeExec.find('[data-test="serialization-surface-tool-response"]').text()).toBe(
'/mcp/call'
)
- expect(codeExec.find('[data-test="serialization-note-tool-response"]').text()).toContain(
- 'describe_tool'
- )
+ const note = codeExec.find('[data-test="serialization-note-tool-response"]')
+ expect(note.text()).toContain('full schemas')
+ expect(note.attributes('title')).toContain('describe_tool')
// …and says nothing of the sort in the mode where it does govern /mcp.
const retrieve = await mountOpen()
@@ -266,6 +286,104 @@ describe('ModeSwitcher', () => {
expect(wrapper.find('[data-test="mode-switcher-pending-badge"]').exists()).toBe(false)
})
+ // The panel must fit without scrolling: a dropdown with an inner scrollbar
+ // hides the half of the decision that is below the fold, which is exactly
+ // what the two tabs exist to prevent. Height is measured in the browser
+ // sweep; here we pin the two things that make it grow — an overflow class on
+ // the container, and long inline copy where a hover hint belongs.
+ // The panel must FIT without scrolling at a normal viewport — the two tabs
+ // exist so neither half of the decision hides below a fold. It keeps a
+ // viewport-relative cap and overflow anyway: dropping them entirely made the
+ // bottom of the panel permanently unreachable at 200% zoom or on a short
+ // window, because the panel is absolutely positioned inside a sticky header
+ // that the page cannot scroll. The cap is sized so it never engages at normal
+ // heights; what keeps it from engaging is the copy budget asserted below.
+ it('fits without scrolling, and keeps its long copy in hover hints', async () => {
+ const wrapper = await mountOpen()
+ const menu = wrapper.find('[data-test="mode-switcher-menu"]')
+ const classes = menu.classes().join(' ')
+ expect(classes).toMatch(/overflow-y-auto/)
+ expect(classes).toMatch(/max-h-\[calc\(100vh/)
+
+ for (const mode of ['retrieve_tools', 'direct', 'code_execution']) {
+ const row = wrapper.find(`[data-test="mode-option-${mode}"]`)
+ expect(row.text().length).toBeLessThan(110)
+ // …and the long form is still one hover away.
+ expect((row.attributes('title') || '').length).toBeGreaterThan(60)
+ }
+ for (const id of ['tool-response-compact', 'direct-tool-response-deferred']) {
+ const row = wrapper.find(`[data-test="serialization-option-${id}"]`)
+ expect(row.text().length).toBeLessThan(80)
+ expect((row.attributes('title') || '').length).toBeGreaterThan(60)
+ }
+ })
+
+ it('links out to the docs from both tabs', async () => {
+ const wrapper = await mountOpen()
+ expect(wrapper.find('[data-test="mode-switcher-surface-doc"]').attributes('href')).toContain(
+ 'docs.mcpproxy.app'
+ )
+ expect(wrapper.find('[data-test="mode-switcher-detail-doc"]').attributes('href')).toContain(
+ 'docs.mcpproxy.app'
+ )
+ })
+
+ // The code-execution surface has NO tool-calling path but the code_execution
+ // tool, which refuses while the feature is off. A prerequisite that only
+ // appears on hover is one the operator meets after restarting into a surface
+ // that finds tools and can call none of them.
+ it('warns inline when Code Exec cannot work yet', async () => {
+ const off = await mountOpen({ code_execution_enabled: false })
+ const prereq = off.find('[data-test="mode-option-code_execution-prereq"]')
+ expect(prereq.exists()).toBe(true)
+ expect(prereq.text()).toContain('Settings')
+ // …and only for the mode it gates.
+ expect(off.find('[data-test="mode-option-direct-prereq"]').exists()).toBe(false)
+
+ const on = await mountOpen({ code_execution_enabled: true })
+ expect(on.find('[data-test="mode-option-code_execution-prereq"]').exists()).toBe(false)
+ })
+
+ it('does not promise a pending surface that cannot serve yet', async () => {
+ const wrapper = await mountOpen({
+ pending_routing_mode: 'code_execution',
+ restart_required: true,
+ code_execution_enabled: false,
+ })
+ const notice = wrapper.find('[data-test="mode-switcher-pending-notice"]').text()
+ expect(notice).toContain('Settings')
+ expect(notice).not.toContain('serves it now')
+ })
+
+ // Selection was conveyed by a background tint and an unlabelled tick — neither
+ // of which reaches assistive tech.
+ it('exposes its selection to assistive tech, on both axes', async () => {
+ const wrapper = await mountOpen({ tool_response_mode: 'compact' })
+
+ const active = wrapper.find('[data-test="mode-option-retrieve_tools"]')
+ expect(active.attributes('role')).toBe('radio')
+ expect(active.attributes('aria-checked')).toBe('true')
+ expect(wrapper.find('[data-test="mode-option-direct"]').attributes('aria-checked')).toBe('false')
+
+ const chosen = wrapper.find('[data-test="serialization-option-tool-response-compact"]')
+ expect(chosen.attributes('aria-checked')).toBe('true')
+ expect(
+ wrapper.find('[data-test="serialization-option-tool-response-full"]').attributes('aria-checked')
+ ).toBe('false')
+ })
+
+ it('sends each serialization axis to its own docs page', async () => {
+ const wrapper = await mountOpen()
+ const retrieveDoc = wrapper.find('[data-test="serialization-doc-tool-response"]').attributes('href')
+ const directDoc = wrapper
+ .find('[data-test="serialization-doc-direct-tool-response"]')
+ .attributes('href')
+ // Two different specs, two different pages — one link for both sent half the
+ // readers to the wrong feature.
+ expect(retrieveDoc).not.toBe(directDoc)
+ expect(directDoc).toContain('schema-deferred')
+ })
+
it('reports a failed write instead of showing a mode it did not set', async () => {
const wrapper = await mountOpen()
patchConfig.mockResolvedValue({ success: false, error: 'invalid routing mode' })
diff --git a/frontend/tests/unit/routing-mode-tooltip.spec.ts b/frontend/tests/unit/routing-mode-tooltip.spec.ts
index f38c22ce0..5501f8209 100644
--- a/frontend/tests/unit/routing-mode-tooltip.spec.ts
+++ b/frontend/tests/unit/routing-mode-tooltip.spec.ts
@@ -6,11 +6,17 @@ import { routingModeMeta } from '@/utils/routingMode'
// rendered as a bare word with nothing to explain it.
describe('routingModeMeta (audit F31)', () => {
- it('explains every mode it labels', () => {
+ // Three lengths of the same fact: the chip, one short always-visible line,
+ // and the full explanation that only ever appears as a hover hint. The
+ // summary has to stay short or the dropdown starts scrolling.
+ it('explains every mode it labels, at both lengths', () => {
for (const mode of ['retrieve_tools', 'direct', 'code_execution']) {
const meta = routingModeMeta(mode)
expect(meta.label.length).toBeGreaterThan(0)
- expect(meta.description.length).toBeGreaterThan(20)
+ expect(meta.summary.length).toBeGreaterThan(10)
+ expect(meta.summary.length).toBeLessThanOrEqual(45)
+ expect(meta.detail.length).toBeGreaterThan(60)
+ expect(meta.endpoint).toMatch(/^\/mcp/)
}
})
@@ -24,6 +30,6 @@ describe('routingModeMeta (audit F31)', () => {
expect(routingModeMeta(undefined).label).toBe('Retrieve')
expect(routingModeMeta('').label).toBe('Retrieve')
expect(routingModeMeta('something_new').label).toBe('Retrieve')
- expect(routingModeMeta('something_new').description).toContain('retrieve_tools')
+ expect(routingModeMeta('something_new').detail).toContain('retrieve_tools')
})
})
diff --git a/internal/httpapi/routing_test.go b/internal/httpapi/routing_test.go
index 707b3413d..874724ff6 100644
--- a/internal/httpapi/routing_test.go
+++ b/internal/httpapi/routing_test.go
@@ -21,6 +21,7 @@ type mockRoutingController struct {
// config — both hot-reloadable, unlike routingMode.
toolResponseMode string
directToolResponseMode string
+ codeExecutionEnabled bool
// desiredRoutingMode is the routing_mode as PERSISTED: a restart-gated
// change lands on disk while the running process keeps serving the old one
// (ApplyConfig's restart contract). Empty means "same as live".
@@ -62,6 +63,7 @@ func (m *mockRoutingController) GetConfig() (*config.Config, error) {
RoutingMode: m.routingMode,
ToolResponseMode: m.toolResponseMode,
DirectToolResponseMode: m.directToolResponseMode,
+ EnableCodeExecution: m.codeExecutionEnabled,
}, nil
}
@@ -359,3 +361,20 @@ func TestHandleGetRouting_PendingRoutingMode(t *testing.T) {
assert.Equal(t, false, data["restart_required"])
})
}
+
+// TestHandleGetRouting_CodeExecutionEnabled: the code-execution surface has no
+// tool-calling path other than the code_execution tool, which is a refusing
+// stub while the feature is off. The Web UI switcher has to be able to warn
+// BEFORE the operator commits to a restart, so the flag rides along with the
+// mode it gates.
+func TestHandleGetRouting_CodeExecutionEnabled(t *testing.T) {
+ t.Run("off by default", func(t *testing.T) {
+ data := getRouting(t, &mockRoutingController{apiKey: "test-key"})
+ assert.Equal(t, false, data["code_execution_enabled"])
+ })
+
+ t.Run("reported when enabled", func(t *testing.T) {
+ data := getRouting(t, &mockRoutingController{apiKey: "test-key", codeExecutionEnabled: true})
+ assert.Equal(t, true, data["code_execution_enabled"])
+ })
+}
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
index d4d6b09a7..d21fbd51a 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -1226,6 +1226,12 @@ func (s *Server) handleGetRouting(w http.ResponseWriter, _ *http.Request) {
// hot-reloadable, unlike routing_mode below.
toolResponseMode := config.ToolResponseModeFull
directToolResponseMode := config.DirectToolResponseModeFull
+ // Code execution is off by default and its surface has NO other tool-calling
+ // path (buildCodeExecModeTools omits call_tool_*), so picking that routing
+ // mode with the flag off produces a surface that can discover tools and call
+ // none of them. The switcher has to be able to warn before the operator
+ // commits to a restart.
+ codeExecutionEnabled := false
if cfg, err := s.controller.GetConfig(); err == nil && cfg != nil {
if cfg.ToolResponseMode != "" {
toolResponseMode = cfg.ToolResponseMode
@@ -1233,6 +1239,7 @@ func (s *Server) handleGetRouting(w http.ResponseWriter, _ *http.Request) {
if cfg.DirectToolResponseMode != "" {
directToolResponseMode = cfg.DirectToolResponseMode
}
+ codeExecutionEnabled = cfg.EnableCodeExecution
}
// routing_mode above is what /mcp is ACTUALLY serving: a routing-mode change
@@ -1257,6 +1264,7 @@ func (s *Server) handleGetRouting(w http.ResponseWriter, _ *http.Request) {
config.RoutingModeDirect,
config.RoutingModeCodeExecution,
},
+ "code_execution_enabled": codeExecutionEnabled,
"tool_response_mode": toolResponseMode,
"direct_tool_response_mode": directToolResponseMode,
"pending_routing_mode": pendingRoutingMode,