Skip to content

Commit 47e61ea

Browse files
authored
Ensure we explicitly map untracked server health errors (and tools not found) (#161)
* Fix bug #158 by explicitly mapping the server health not tracked error * Add tests for API level and health tracker level code * Add pre-emptive fix for unhandled tools not found error (and tests) * Update code comments for clarity and to aid future devs
1 parent 0f0b77b commit 47e61ea

File tree

4 files changed

+215
-8
lines changed

4 files changed

+215
-8
lines changed

internal/api/health_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package api
33
import (
44
"fmt"
55
"testing"
6+
"time"
67

78
"github.com/stretchr/testify/require"
89

910
"github.com/mozilla-ai/mcpd/v2/internal/domain"
11+
"github.com/mozilla-ai/mcpd/v2/internal/errors"
1012
)
1113

1214
func TestParseHealthStatus_ValidCases(t *testing.T) {
@@ -57,3 +59,72 @@ func TestParseHealthStatus_InvalidCase(t *testing.T) {
5759
require.Error(t, err)
5860
require.EqualError(t, err, fmt.Sprintf("unknown health status: %s", input))
5961
}
62+
63+
// mockHealthMonitor implements the MCPHealthMonitor interface for testing.
64+
type mockHealthMonitor struct {
65+
servers map[string]domain.ServerHealth
66+
}
67+
68+
func newMockHealthMonitor() *mockHealthMonitor {
69+
return &mockHealthMonitor{
70+
servers: make(map[string]domain.ServerHealth),
71+
}
72+
}
73+
74+
func (m *mockHealthMonitor) Status(name string) (domain.ServerHealth, error) {
75+
if health, ok := m.servers[name]; ok {
76+
return health, nil
77+
}
78+
return domain.ServerHealth{}, fmt.Errorf("%w: %s", errors.ErrHealthNotTracked, name)
79+
}
80+
81+
func (m *mockHealthMonitor) List() []domain.ServerHealth {
82+
servers := make([]domain.ServerHealth, 0, len(m.servers))
83+
for _, server := range m.servers {
84+
servers = append(servers, server)
85+
}
86+
return servers
87+
}
88+
89+
func (m *mockHealthMonitor) Update(name string, status domain.HealthStatus, latency *time.Duration) error {
90+
m.servers[name] = domain.ServerHealth{
91+
Name: name,
92+
Status: status,
93+
Latency: latency,
94+
}
95+
return nil
96+
}
97+
98+
func TestHandleHealthServer_ServerNotTracked(t *testing.T) {
99+
t.Parallel()
100+
101+
monitor := newMockHealthMonitor()
102+
103+
// Try to get health for a server that doesn't exist.
104+
result, err := handleHealthServer(monitor, "nonexistent-server")
105+
require.Error(t, err)
106+
require.Nil(t, result)
107+
108+
require.ErrorIs(t, err, errors.ErrHealthNotTracked)
109+
}
110+
111+
func TestHandleHealthServer_ServerExists(t *testing.T) {
112+
t.Parallel()
113+
114+
monitor := newMockHealthMonitor()
115+
116+
// Add a server to the monitor.
117+
latency := 100 * time.Millisecond
118+
err := monitor.Update("test-server", domain.HealthStatusOK, &latency)
119+
require.NoError(t, err)
120+
121+
// Get health for existing server.
122+
result, err := handleHealthServer(monitor, "test-server")
123+
require.NoError(t, err)
124+
require.NotNil(t, result)
125+
126+
require.Equal(t, "test-server", result.Body.Name)
127+
require.Equal(t, HealthStatusOK, result.Body.Status)
128+
require.NotNil(t, result.Body.Latency)
129+
require.Equal(t, "100ms", *result.Body.Latency)
130+
}

internal/daemon/api_server.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,13 +153,35 @@ func (a *APIServer) applyCORS(mux *chi.Mux) {
153153
mux.Use(cors.Handler(corsOptions))
154154
}
155155

156-
// mapError maps application domain errors to API errors.
156+
// mapError maps application domain errors to appropriate HTTP status codes.
157+
//
158+
// This function is the central place where domain errors from internal/errors are converted to HTTP responses.
159+
// When adding new errors to internal/errors/errors.go, you MUST add them here to prevent them from falling
160+
// through to the default case which returns HTTP 500.
161+
//
162+
// NOTE: Keep this function in sync with internal/errors/errors.go.
163+
// Every error defined there should have an explicit case here otherwise it will default to 500.
164+
//
165+
// Mapping guidelines:
166+
// - 400: Client errors (bad input, invalid requests)
167+
// - 403: Authorization/permission errors
168+
// - 404: Resource not found errors
169+
// - 502: External service/dependency failures
170+
// - 500: Unexpected internal errors (default case)
171+
//
172+
// Don't forget to:
173+
// 1. Add test cases to TestMapError (internal/daemon/api_server_test.go)
174+
// 2. Update the documentation in internal/errors/errors.go
157175
func mapError(logger hclog.Logger, err error) huma.StatusError {
158176
switch {
159177
case stdErrors.Is(err, errors.ErrBadRequest):
160178
return huma.Error400BadRequest(err.Error())
161179
case stdErrors.Is(err, errors.ErrServerNotFound):
162180
return huma.Error404NotFound(err.Error())
181+
case stdErrors.Is(err, errors.ErrToolsNotFound):
182+
return huma.Error404NotFound(err.Error())
183+
case stdErrors.Is(err, errors.ErrHealthNotTracked):
184+
return huma.Error404NotFound(err.Error())
163185
case stdErrors.Is(err, errors.ErrToolForbidden):
164186
return huma.Error403Forbidden(err.Error())
165187
case stdErrors.Is(err, errors.ErrToolListFailed):

internal/daemon/api_server_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
package daemon
22

33
import (
4+
"fmt"
45
"testing"
56
"time"
67

78
"github.com/go-chi/chi/v5"
89
"github.com/hashicorp/go-hclog"
910
"github.com/stretchr/testify/require"
11+
12+
"github.com/mozilla-ai/mcpd/v2/internal/errors"
1013
)
1114

1215
func TestNewAPIServer_AppliesDefaults(t *testing.T) {
@@ -284,3 +287,70 @@ func testNewChiMux(t *testing.T) *chi.Mux {
284287
t.Helper()
285288
return chi.NewMux()
286289
}
290+
291+
func TestMapError(t *testing.T) {
292+
t.Parallel()
293+
294+
logger := hclog.NewNullLogger()
295+
296+
tests := []struct {
297+
name string
298+
err error
299+
expectedStatus int
300+
}{
301+
{
302+
name: "ErrBadRequest maps to 400",
303+
err: errors.ErrBadRequest,
304+
expectedStatus: 400,
305+
},
306+
{
307+
name: "ErrServerNotFound maps to 404",
308+
err: errors.ErrServerNotFound,
309+
expectedStatus: 404,
310+
},
311+
{
312+
name: "ErrToolsNotFound maps to 404",
313+
err: errors.ErrToolsNotFound,
314+
expectedStatus: 404,
315+
},
316+
{
317+
name: "ErrHealthNotTracked maps to 404",
318+
err: errors.ErrHealthNotTracked,
319+
expectedStatus: 404,
320+
},
321+
{
322+
name: "ErrToolForbidden maps to 403",
323+
err: errors.ErrToolForbidden,
324+
expectedStatus: 403,
325+
},
326+
{
327+
name: "ErrToolListFailed maps to 502",
328+
err: errors.ErrToolListFailed,
329+
expectedStatus: 502,
330+
},
331+
{
332+
name: "ErrToolCallFailed maps to 502",
333+
err: errors.ErrToolCallFailed,
334+
expectedStatus: 502,
335+
},
336+
{
337+
name: "ErrToolCallFailedUnknown maps to 502",
338+
err: errors.ErrToolCallFailedUnknown,
339+
expectedStatus: 502,
340+
},
341+
{
342+
name: "Unknown error maps to 500",
343+
err: fmt.Errorf("unknown error"),
344+
expectedStatus: 500,
345+
},
346+
}
347+
348+
for _, tc := range tests {
349+
t.Run(tc.name, func(t *testing.T) {
350+
t.Parallel()
351+
352+
statusErr := mapError(logger, tc.err)
353+
require.Equal(t, tc.expectedStatus, statusErr.GetStatus())
354+
})
355+
}
356+
}

internal/errors/errors.go

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,60 @@
1+
// Package errors defines domain-level errors used throughout the application.
2+
// These errors represent business logic failures and are mapped to appropriate HTTP status codes at the API boundary.
3+
//
4+
// NOTE: Important for developers
5+
// When adding a new error here, you MUST consider how it should be handled when returned from API endpoints.
6+
//
7+
// Unmapped errors will default to HTTP 500 Internal Server Error.
8+
//
9+
// Don't forget to:
10+
// 1. Add your error to mapError (internal/daemon/api_server.go)
11+
// 2. Add a test case to TestMapError (internal/daemon/api_server_test.go)
12+
// 3. Consider if existing handler tests need updates
113
package errors
214

315
import (
416
"errors"
517
)
618

719
var (
8-
ErrBadRequest = errors.New("bad request")
9-
ErrServerNotFound = errors.New("server not found")
10-
ErrToolsNotFound = errors.New("tools not found")
11-
ErrToolForbidden = errors.New("tool not allowed")
12-
ErrToolListFailed = errors.New("tool list failed")
13-
ErrToolCallFailed = errors.New("tool call failed")
20+
// ErrBadRequest indicates that the client provided invalid input or made a malformed request.
21+
// This typically results from validation failures or incorrect request parameters.
22+
// Recommended to map to HTTP 400 Bad Request.
23+
ErrBadRequest = errors.New("bad request")
24+
25+
// ErrServerNotFound indicates that the requested MCP server does not exist or is not configured.
26+
// This occurs when trying to access operations on a server that hasn't been registered.
27+
// Recommended to map to HTTP 404 Not Found.
28+
ErrServerNotFound = errors.New("server not found")
29+
30+
// ErrToolsNotFound indicates that no tools are configured or available for the specified server.
31+
// This can happen when a server exists but has no tools defined.
32+
// Recommended to map to HTTP 404 Not Found.
33+
ErrToolsNotFound = errors.New("tools not found")
34+
35+
// ErrToolForbidden indicates that the requested tool either does not exist for the MCP server,
36+
// or exists but is not allowed to be called.
37+
// This occurs when a tool is not in the server's allowed tools list.
38+
// Recommended to map to HTTP 403 Forbidden.
39+
ErrToolForbidden = errors.New("tool not allowed")
40+
41+
// ErrToolListFailed indicates that listing tools from an MCP server failed.
42+
// This represents a communication or protocol error with the external MCP server.
43+
// Recommended to map to HTTP 502 Bad Gateway.
44+
ErrToolListFailed = errors.New("tool list failed")
45+
46+
// ErrToolCallFailed indicates that calling a tool on an MCP server failed.
47+
// This represents a communication or execution error with the external MCP server.
48+
// Recommended to map to HTTP 502 Bad Gateway.
49+
ErrToolCallFailed = errors.New("tool call failed")
50+
51+
// ErrToolCallFailedUnknown indicates that calling a tool failed for an unknown/unexpected reason.
52+
// This is used when the exact cause of the tool call failure cannot be determined.
53+
// Recommended to map to HTTP 502 Bad Gateway.
1454
ErrToolCallFailedUnknown = errors.New("tool call failed (unknown error)")
15-
ErrHealthNotTracked = errors.New("server health is not being tracked")
55+
56+
// ErrHealthNotTracked indicates that health monitoring is not enabled for the specified server.
57+
// This occurs when trying to get health status for a server that isn't being monitored.
58+
// Recommended to map to HTTP 404 Not Found.
59+
ErrHealthNotTracked = errors.New("server health is not being tracked")
1660
)

0 commit comments

Comments
 (0)