From c6c328e5174d7d91ac05b62126b5408f82a93a0f Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Mon, 4 May 2026 20:59:15 +0200 Subject: [PATCH] fix(mcp): map tool-not-found to CodeNotFound via registry sentinel Co-Authored-By: Claude Sonnet 4.6 --- internal/mcp/server.go | 4 +--- internal/mcp/server_test.go | 28 ++++++++++++++++++++++++++++ internal/registry/registry.go | 5 ++++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 929ae3d..613d7c6 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -90,7 +90,7 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) { out, err := s.opts.Registry.Dispatch(r.Context(), p.Name, p.Arguments) if err != nil { code := -32000 - if errors.Is(err, ErrToolNotFound) { + if errors.Is(err, registry.ErrToolNotFound) { code = CodeNotFound } writeJSON(w, http.StatusOK, @@ -125,8 +125,6 @@ func (s *Server) handleGET(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() } -var ErrToolNotFound = errors.New("tool not found") - func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index ac1c3c2..441cd06 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -117,3 +117,31 @@ func TestPostBodyTooLarge(t *testing.T) { assert.NotEqual(t, http.StatusOK, rr.Code, "oversized body must not return 200") assert.Equal(t, http.StatusBadRequest, rr.Code) } + +func TestToolsCallToolNotFound(t *testing.T) { + srv := newServer(t) + // Initialize to get a session ID. + init := postJSON(t, srv, map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": map[string]any{"protocolVersion": "2025-06-18"}, + }, "") + sid := init.Header().Get("Mcp-Session-Id") + + rr := postJSON(t, srv, map[string]any{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": map[string]any{"name": "nonexistent", "arguments": map[string]any{}}, + }, sid) + require.Equal(t, http.StatusOK, rr.Code) + + var resp map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + rpcErr, ok := resp["error"].(map[string]any) + require.True(t, ok, "expected error field in response") + code := int(rpcErr["code"].(float64)) + assert.Equal(t, -32002, code, "expected CodeNotFound (-32002) for missing tool") + assert.NotEmpty(t, rpcErr["message"]) +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 01c870e..949ca64 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -4,8 +4,11 @@ import ( "context" "encoding/json" "errors" + "fmt" ) +var ErrToolNotFound = errors.New("tool not found") + type ToolDescriptor struct { Name string `json:"name"` Description string `json:"description"` @@ -36,7 +39,7 @@ func (r *Registry) Tools() []ToolDescriptor { func (r *Registry) Dispatch(ctx context.Context, name string, args json.RawMessage) (json.RawMessage, error) { t, ok := r.tools[name] if !ok { - return nil, errors.New("tool not found: " + name) + return nil, fmt.Errorf("tool %q: %w", name, ErrToolNotFound) } return t.Call(ctx, args) }