Skip to content

Commit 88bd598

Browse files
authored
Merge pull request #327 from smart-mcp-proxy/031-routing-modes
feat: add MCP routing modes (direct, code_execution, retrieve_tools)
2 parents 6e2862b + dc8de2d commit 88bd598

43 files changed

Lines changed: 2131 additions & 69 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/mcpproxy/doctor_cmd.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,23 @@ func displaySecurityFeaturesStatus() {
421421
return
422422
}
423423

424+
// Routing Mode status (Spec 031)
425+
routingMode := cfg.RoutingMode
426+
if routingMode == "" {
427+
routingMode = config.RoutingModeRetrieveTools
428+
}
429+
fmt.Printf(" Routing Mode: %s\n", routingMode)
430+
switch routingMode {
431+
case config.RoutingModeDirect:
432+
fmt.Println(" All upstream tools exposed directly via /mcp endpoint")
433+
case config.RoutingModeCodeExecution:
434+
fmt.Println(" JS orchestration via code_execution tool")
435+
default:
436+
fmt.Println(" BM25 search via retrieve_tools + call_tool variants")
437+
}
438+
fmt.Printf(" Endpoints: /mcp/all (direct), /mcp/code (code_execution), /mcp/call (retrieve_tools)\n")
439+
fmt.Println()
440+
424441
// Sensitive Data Detection status
425442
sddConfig := cfg.SensitiveDataDetection
426443
if sddConfig == nil || sddConfig.IsEnabled() {

cmd/mcpproxy/status_cmd.go

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type StatusInfo struct {
2828
UptimeSeconds float64 `json:"uptime_seconds,omitempty"`
2929
APIKey string `json:"api_key"`
3030
WebUIURL string `json:"web_ui_url"`
31+
RoutingMode string `json:"routing_mode"`
3132
Servers *ServerCounts `json:"servers,omitempty"`
3233
SocketPath string `json:"socket_path,omitempty"`
3334
ConfigPath string `json:"config_path,omitempty"`
@@ -158,11 +159,17 @@ func collectStatusFromDaemon(cfg *config.Config, socketPath, configPath string)
158159
defer cancel()
159160

160161
info := &StatusInfo{
161-
State: "Running",
162-
Edition: Edition,
163-
APIKey: cfg.APIKey,
164-
SocketPath: socketPath,
165-
ConfigPath: configPath,
162+
State: "Running",
163+
Edition: Edition,
164+
APIKey: cfg.APIKey,
165+
RoutingMode: cfg.RoutingMode,
166+
SocketPath: socketPath,
167+
ConfigPath: configPath,
168+
}
169+
170+
// Apply routing mode default if empty
171+
if info.RoutingMode == "" {
172+
info.RoutingMode = config.RoutingModeRetrieveTools
166173
}
167174

168175
// Add teams info if available
@@ -220,13 +227,19 @@ func collectStatusFromConfig(cfg *config.Config, socketPath, configPath string)
220227
listenAddr = "127.0.0.1:8080"
221228
}
222229

230+
routingMode := cfg.RoutingMode
231+
if routingMode == "" {
232+
routingMode = config.RoutingModeRetrieveTools
233+
}
234+
223235
info := &StatusInfo{
224-
State: "Not running",
225-
Edition: Edition,
226-
ListenAddr: listenAddr + " (configured)",
227-
APIKey: cfg.APIKey,
228-
WebUIURL: statusBuildWebUIURL(listenAddr, cfg.APIKey),
229-
ConfigPath: configPath,
236+
State: "Not running",
237+
Edition: Edition,
238+
ListenAddr: listenAddr + " (configured)",
239+
APIKey: cfg.APIKey,
240+
WebUIURL: statusBuildWebUIURL(listenAddr, cfg.APIKey),
241+
RoutingMode: routingMode,
242+
ConfigPath: configPath,
230243
}
231244

232245
info.TeamsInfo = collectTeamsInfo(cfg)
@@ -351,6 +364,7 @@ func printStatusTable(info *StatusInfo) {
351364
}
352365

353366
fmt.Printf(" %-12s %s\n", "API Key:", info.APIKey)
367+
fmt.Printf(" %-12s %s\n", "Routing:", info.RoutingMode)
354368
fmt.Printf(" %-12s %s\n", "Web UI:", info.WebUIURL)
355369

356370
if info.Servers != nil {

cmd/mcpproxy/status_cmd_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,129 @@ func TestStatusJSONOutput(t *testing.T) {
449449
}
450450
}
451451

452+
func TestStatusRoutingModeInTable(t *testing.T) {
453+
tests := []struct {
454+
name string
455+
routingMode string
456+
expected string
457+
}{
458+
{
459+
name: "retrieve_tools mode",
460+
routingMode: "retrieve_tools",
461+
expected: "retrieve_tools",
462+
},
463+
{
464+
name: "direct mode",
465+
routingMode: "direct",
466+
expected: "direct",
467+
},
468+
{
469+
name: "code_execution mode",
470+
routingMode: "code_execution",
471+
expected: "code_execution",
472+
},
473+
}
474+
475+
for _, tt := range tests {
476+
t.Run(tt.name, func(t *testing.T) {
477+
info := &StatusInfo{
478+
State: "Running",
479+
Edition: "personal",
480+
ListenAddr: "127.0.0.1:8080",
481+
APIKey: "a1b2****a1b2",
482+
WebUIURL: "http://127.0.0.1:8080/ui/?apikey=test",
483+
RoutingMode: tt.routingMode,
484+
}
485+
486+
old := os.Stdout
487+
r, w, _ := os.Pipe()
488+
os.Stdout = w
489+
490+
printStatusTable(info)
491+
492+
w.Close()
493+
os.Stdout = old
494+
495+
buf := make([]byte, 4096)
496+
n, _ := r.Read(buf)
497+
output := string(buf[:n])
498+
499+
if !strings.Contains(output, "Routing:") {
500+
t.Errorf("expected output to contain 'Routing:', output:\n%s", output)
501+
}
502+
if !strings.Contains(output, tt.expected) {
503+
t.Errorf("expected output to contain %q, output:\n%s", tt.expected, output)
504+
}
505+
})
506+
}
507+
}
508+
509+
func TestStatusRoutingModeInJSON(t *testing.T) {
510+
info := &StatusInfo{
511+
State: "Running",
512+
Edition: "personal",
513+
ListenAddr: "127.0.0.1:8080",
514+
APIKey: "testkey",
515+
WebUIURL: "http://127.0.0.1:8080/ui/",
516+
RoutingMode: "direct",
517+
}
518+
519+
old := os.Stdout
520+
r, w, _ := os.Pipe()
521+
os.Stdout = w
522+
523+
err := printStatusJSON(info)
524+
525+
w.Close()
526+
os.Stdout = old
527+
528+
if err != nil {
529+
t.Fatalf("printStatusJSON failed: %v", err)
530+
}
531+
532+
buf := make([]byte, 8192)
533+
n, _ := r.Read(buf)
534+
output := string(buf[:n])
535+
536+
var result StatusInfo
537+
if jsonErr := json.Unmarshal([]byte(output), &result); jsonErr != nil {
538+
t.Fatalf("invalid JSON: %v\nOutput: %s", jsonErr, output)
539+
}
540+
541+
if result.RoutingMode != "direct" {
542+
t.Errorf("expected routing_mode 'direct', got %q", result.RoutingMode)
543+
}
544+
}
545+
546+
func TestCollectStatusFromConfigRoutingMode(t *testing.T) {
547+
t.Run("uses config routing mode", func(t *testing.T) {
548+
cfg := &config.Config{
549+
Listen: "127.0.0.1:8080",
550+
APIKey: "testkey",
551+
RoutingMode: "direct",
552+
}
553+
554+
info := collectStatusFromConfig(cfg, "/tmp/test.sock", "/tmp/config.json")
555+
556+
if info.RoutingMode != "direct" {
557+
t.Errorf("expected routing mode 'direct', got %q", info.RoutingMode)
558+
}
559+
})
560+
561+
t.Run("defaults to retrieve_tools when empty", func(t *testing.T) {
562+
cfg := &config.Config{
563+
Listen: "127.0.0.1:8080",
564+
APIKey: "testkey",
565+
}
566+
567+
info := collectStatusFromConfig(cfg, "/tmp/test.sock", "/tmp/config.json")
568+
569+
if info.RoutingMode != config.RoutingModeRetrieveTools {
570+
t.Errorf("expected routing mode %q, got %q", config.RoutingModeRetrieveTools, info.RoutingMode)
571+
}
572+
})
573+
}
574+
452575
// parseTestDuration is a helper to parse duration strings for tests.
453576
func parseTestDuration(s string) (time.Duration, error) {
454577
return time.ParseDuration(s)

frontend/src/App.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ onMounted(async () => {
110110
111111
// Fetch version info
112112
systemStore.fetchInfo()
113+
114+
// Fetch routing mode info
115+
systemStore.fetchRouting()
113116
})
114117
115118
onUnmounted(() => {

frontend/src/components/TopHeader.vue

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@
6464
<span class="text-xs opacity-60">Tools</span>
6565
</div>
6666

67+
<!-- Routing Mode -->
68+
<div class="flex items-center space-x-2 px-3 py-2 bg-base-200 rounded-lg text-sm">
69+
<span class="text-xs opacity-60">Mode:</span>
70+
<span class="font-medium">{{ routingModeLabel }}</span>
71+
</div>
72+
6773
<!-- Proxy Address with Copy -->
6874
<div v-if="systemStore.listenAddr" class="flex items-center space-x-2 px-3 py-2 bg-base-200 rounded-lg">
6975
<span class="text-xs font-medium opacity-60">Proxy:</span>
@@ -105,6 +111,18 @@ const authStore = useAuthStore()
105111
106112
const addServerLabel = computed(() => authStore.isTeamsEdition ? 'Add Personal Server' : 'Add Server')
107113
114+
const routingModeLabel = computed(() => {
115+
const mode = systemStore.routingMode
116+
switch (mode) {
117+
case 'direct':
118+
return 'Direct'
119+
case 'code_execution':
120+
return 'Code Exec'
121+
default:
122+
return 'Retrieve'
123+
}
124+
})
125+
108126
const searchQuery = ref('')
109127
const copyTooltip = ref('Copy MCP address')
110128
const showAddServerModal = ref(false)

frontend/src/services/api.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { APIResponse, Server, Tool, ToolApproval, SearchResult, StatusUpdate, SecretRef, MigrationAnalysis, ConfigSecretsResponse, GetToolCallsResponse, GetToolCallDetailResponse, GetServerToolCallsResponse, GetConfigResponse, ValidateConfigResponse, ConfigApplyResult, ServerTokenMetrics, GetRegistriesResponse, SearchRegistryServersResponse, RepositoryServer, GetSessionsResponse, GetSessionDetailResponse, InfoResponse, ActivityListResponse, ActivityDetailResponse, ActivitySummaryResponse, ImportResponse, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse } from '@/types'
1+
import type { APIResponse, Server, Tool, ToolApproval, SearchResult, StatusUpdate, SecretRef, MigrationAnalysis, ConfigSecretsResponse, GetToolCallsResponse, GetToolCallDetailResponse, GetServerToolCallsResponse, GetConfigResponse, ValidateConfigResponse, ConfigApplyResult, ServerTokenMetrics, GetRegistriesResponse, SearchRegistryServersResponse, RepositoryServer, GetSessionsResponse, GetSessionDetailResponse, InfoResponse, ActivityListResponse, ActivityDetailResponse, ActivitySummaryResponse, ImportResponse, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse, RoutingInfo } from '@/types'
22

33
// Event types for API service
44
export interface APIAuthEvent {
@@ -205,8 +205,13 @@ class APIService {
205205
}
206206

207207
// Status endpoint
208-
async getStatus(): Promise<APIResponse<{ edition: string; running: boolean }>> {
209-
return this.request<{ edition: string; running: boolean }>('/api/v1/status')
208+
async getStatus(): Promise<APIResponse<{ edition: string; running: boolean; routing_mode: string }>> {
209+
return this.request<{ edition: string; running: boolean; routing_mode: string }>('/api/v1/status')
210+
}
211+
212+
// Routing mode endpoint
213+
async getRouting(): Promise<APIResponse<RoutingInfo>> {
214+
return this.request<RoutingInfo>('/api/v1/routing')
210215
}
211216

212217
// Server endpoints

frontend/src/stores/system.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { defineStore } from 'pinia'
22
import { ref, computed } from 'vue'
3-
import type { StatusUpdate, Theme, Toast, InfoResponse } from '@/types'
3+
import type { StatusUpdate, Theme, Toast, InfoResponse, RoutingInfo } from '@/types'
44
import api from '@/services/api'
55

66
export const useSystemStore = defineStore('system', () => {
@@ -11,6 +11,7 @@ export const useSystemStore = defineStore('system', () => {
1111
const currentTheme = ref<string>('corporate')
1212
const toasts = ref<Toast[]>([])
1313
const info = ref<InfoResponse | null>(null)
14+
const routing = ref<RoutingInfo | null>(null)
1415

1516
// Available themes
1617
const themes: Theme[] = [
@@ -59,6 +60,9 @@ export const useSystemStore = defineStore('system', () => {
5960
const updateAvailable = computed(() => info.value?.update?.available ?? false)
6061
const latestVersion = computed(() => info.value?.update?.latest_version ?? '')
6162

63+
// Routing mode
64+
const routingMode = computed(() => routing.value?.routing_mode ?? status.value?.routing_mode ?? 'retrieve_tools')
65+
6266
// Actions
6367
function connectEventSource() {
6468
if (eventSource.value) {
@@ -348,6 +352,17 @@ export const useSystemStore = defineStore('system', () => {
348352
}
349353
}
350354

355+
async function fetchRouting() {
356+
try {
357+
const response = await api.getRouting()
358+
if (response.success && response.data) {
359+
routing.value = response.data
360+
}
361+
} catch (error) {
362+
console.error('Failed to fetch routing:', error)
363+
}
364+
}
365+
351366
// Initialize theme on store creation
352367
loadTheme()
353368

@@ -359,6 +374,7 @@ export const useSystemStore = defineStore('system', () => {
359374
toasts,
360375
themes,
361376
info,
377+
routing,
362378

363379
// Computed
364380
isRunning,
@@ -368,6 +384,7 @@ export const useSystemStore = defineStore('system', () => {
368384
version,
369385
updateAvailable,
370386
latestVersion,
387+
routingMode,
371388

372389
// Actions
373390
connectEventSource,
@@ -378,5 +395,6 @@ export const useSystemStore = defineStore('system', () => {
378395
removeToast,
379396
clearToasts,
380397
fetchInfo,
398+
fetchRouting,
381399
}
382-
})
400+
})

frontend/src/types/api.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export interface SearchResult {
115115
export interface StatusUpdate {
116116
running: boolean
117117
listen_addr: string
118+
routing_mode?: string
118119
upstream_stats: {
119120
connected_servers: number
120121
total_servers: number
@@ -124,6 +125,19 @@ export interface StatusUpdate {
124125
timestamp: number
125126
}
126127

128+
// Routing mode types
129+
export interface RoutingInfo {
130+
routing_mode: string
131+
description: string
132+
endpoints: {
133+
default: string
134+
direct: string
135+
code_execution: string
136+
retrieve_tools: string
137+
}
138+
available_modes: string[]
139+
}
140+
127141
// Dashboard stats
128142
export interface DashboardStats {
129143
servers: {

0 commit comments

Comments
 (0)