From c4d888082dd0fbec6cfc71aa307e4bf7aef98bf2 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 13 May 2026 21:39:43 +0300 Subject: [PATCH] fix(mcp): gate suggester on leading verb match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production v0.18.1 logs showed read_message → "did you mean: send_message" — two edits away by Levenshtein, but the opposite intent. An agent asking to READ a single message getting nudged toward SEND is actively misleading. Same trap was active for anything sharing a verb-less suffix like _message, _channel, _task. Constrain the Levenshtein candidates to those whose leading verb (token before the first underscore) matches the input verb exactly. Substring matching is unchanged. Drop the now-stale sned_message typo test case (cross-verb typo correction is no longer in scope) and add a regression test covering read_message, delete_message, fetch_channel. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/mcp/bridge.go | 26 +++++++++++++++++++++++--- internal/mcp/bridge_test.go | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/internal/mcp/bridge.go b/internal/mcp/bridge.go index f734ae5..14057f8 100644 --- a/internal/mcp/bridge.go +++ b/internal/mcp/bridge.go @@ -250,8 +250,12 @@ var knownBridgeActions = []string{ // suggestBridgeAction returns the closest known action name to `name`, or "" // if nothing is plausibly close. Strategy: cheap prefix/substring check first, -// then Levenshtein within distance 3. Distance threshold scales with the -// length of the input so very short strings don't false-match. +// then Levenshtein within distance 3 — but Levenshtein matches must share the +// leading verb (the token before the first underscore) with the candidate. +// Without that constraint, e.g. `read_message` is two edits from `send_message` +// and gets suggested, which is the opposite intent and actively misleading. +// Distance threshold scales with the length of the input so very short strings +// don't false-match. func suggestBridgeAction(name string) string { if name == "" { return "" @@ -265,14 +269,19 @@ func suggestBridgeAction(name string) string { } } - // 2. Levenshtein on full names. + // 2. Levenshtein on full names, gated on a matching leading verb so + // `read_message` is not "fixed" by suggesting `send_message`. threshold := 3 if len(name) <= 6 { threshold = 2 } + inputVerb := leadingVerb(lower) bestDist := threshold + 1 best := "" for _, candidate := range knownBridgeActions { + if leadingVerb(candidate) != inputVerb { + continue + } d := levenshtein(lower, candidate) if d < bestDist { bestDist = d @@ -285,6 +294,17 @@ func suggestBridgeAction(name string) string { return "" } +// leadingVerb returns the substring before the first underscore, or the +// whole string if there is no underscore. Used to gate Levenshtein +// suggestions so a shared suffix (like `_message`) doesn't pair `read_…` +// with `send_…`. +func leadingVerb(s string) string { + if i := strings.IndexByte(s, '_'); i >= 0 { + return s[:i] + } + return s +} + // levenshtein computes the Levenshtein edit distance between a and b using // a single rolling row of O(min(len)) space. Pure Go, no deps. func levenshtein(a, b string) int { diff --git a/internal/mcp/bridge_test.go b/internal/mcp/bridge_test.go index 0461257..e3d0e09 100644 --- a/internal/mcp/bridge_test.go +++ b/internal/mcp/bridge_test.go @@ -612,10 +612,10 @@ func TestBridge_UnknownAction_Suggestion(t *testing.T) { }{ // substring hit: "send" is contained in "send_message" {input: "send", wantContains: "send_message"}, - // Levenshtein: typo "sned_message" → "send_message" (distance 2) - {input: "sned_message", wantContains: "send_message"}, - // Levenshtein: "list_channel" → "list_channels" (distance 1) + // Levenshtein within a shared verb: "list_channel" → "list_channels" (distance 1) {input: "list_channel", wantContains: "list_channels"}, + // Levenshtein within a shared verb: "get_channel_message" → "get_channel_messages" + {input: "get_channel_message", wantContains: "get_channel_messages"}, } for _, tt := range tests { @@ -650,6 +650,33 @@ func TestBridge_UnknownAction_NoSuggestion(t *testing.T) { } } +// TestBridge_UnknownAction_NoCrossVerbSuggestion guards against the suggester +// pairing actions that share a suffix but have opposite intent — e.g. +// `read_message` is two edits from `send_message`, but suggesting "send" to an +// agent that asked to read is actively misleading. The leading-verb gate in +// suggestBridgeAction must prevent this. +func TestBridge_UnknownAction_NoCrossVerbSuggestion(t *testing.T) { + bridge, _, _, _ := newTestBridge(t) + ctx := context.Background() + + cases := []string{ + "read_message", // would have suggested send_message (distance 2) + "delete_message", // would have suggested send_message (distance 3) + "fetch_channel", // unrelated verb; must not suggest send/list/get + } + for _, in := range cases { + t.Run(in, func(t *testing.T) { + _, err := bridge.Call(ctx, in, map[string]any{}) + if err == nil { + t.Fatalf("expected error for %q", in) + } + if strings.Contains(err.Error(), "did you mean") { + t.Errorf("cross-verb suggestion leaked for %q: %v", in, err) + } + }) + } +} + func TestLevenshtein(t *testing.T) { tests := []struct { a, b string