diff --git a/client.go b/client.go index 5a0c0c5..bb87ecd 100644 --- a/client.go +++ b/client.go @@ -142,6 +142,15 @@ var sensitiveBodyKeys = map[string]struct{}{ "credentials": {}, } +// redactChildrenKeys enumerates normalized JSON keys whose nested values are +// always redacted regardless of inner key name. These containers (env, headers) +// hold user-chosen keys that frequently carry credentials, so the allow-list +// approach in sensitiveBodyKeys cannot catch them. +var redactChildrenKeys = map[string]struct{}{ + "env": {}, + "headers": {}, +} + // sanitizeBody redacts values of well-known sensitive JSON keys so that // secrets do not appear in request/response logs. It is best-effort: empty or // non-JSON bodies pass through unchanged. Callers must still use sanitizeURL @@ -178,6 +187,16 @@ func sanitizeJSONValue(v any) (any, bool) { continue } + // When the value is a container whose user-chosen keys may hold + // credentials (env, headers), redact every leaf inside without + // inspecting inner names — the allow-list cannot anticipate + // arbitrary user-supplied key names like OPENAI_API_KEY. + if shouldRedactChildren(key) { + sanitized[key] = redactAllLeaves(item) + redacted = true + continue + } + sanitizedItem, itemRedacted := sanitizeJSONValue(item) sanitized[key] = sanitizedItem redacted = redacted || itemRedacted @@ -197,11 +216,38 @@ func sanitizeJSONValue(v any) (any, bool) { } } +// redactAllLeaves walks v and replaces every non-container leaf with +// "[REDACTED]", preserving the surrounding map/slice shape so the log entry +// still hints at the payload structure. +func redactAllLeaves(v any) any { + switch value := v.(type) { + case map[string]any: + out := make(map[string]any, len(value)) + for key, item := range value { + out[key] = redactAllLeaves(item) + } + return out + case []any: + out := make([]any, len(value)) + for i, item := range value { + out[i] = redactAllLeaves(item) + } + return out + default: + return "[REDACTED]" + } +} + func isSensitiveBodyKey(key string) bool { _, ok := sensitiveBodyKeys[normalizeSensitiveBodyKey(key)] return ok } +func shouldRedactChildren(key string) bool { + _, ok := redactChildrenKeys[normalizeSensitiveBodyKey(key)] + return ok +} + func normalizeSensitiveBodyKey(key string) string { var b strings.Builder b.Grow(len(key)) diff --git a/client_test.go b/client_test.go index 504dd53..df0071c 100644 --- a/client_test.go +++ b/client_test.go @@ -619,6 +619,33 @@ func TestSanitizeBodyRedactsNestedCaseInsensitiveAliases(t *testing.T) { } } +// env and headers maps carry user-chosen keys (e.g. OPENAI_API_KEY, +// X-Prom-Token) whose names the static allow-list cannot anticipate. Every +// value under these parents must be redacted unconditionally, while sibling +// fields stay visible so the log still indicates payload shape. +func TestSanitizeBodyRedactsAllChildrenOfEnvAndHeaders(t *testing.T) { + input := `{"env":{"OPENAI_API_KEY":"sk-secret","FOO":"bar"},"headers":{"X-Custom":"tok"},"server_name":"prom-prod"}` + + got := sanitizeBody(input) + + for _, secret := range []string{"sk-secret", "bar", "tok"} { + if strings.Contains(got, secret) { + t.Errorf("sanitizeBody(%q) = %q; must not contain %q", input, got, secret) + } + } + + for _, want := range []string{ + `"OPENAI_API_KEY":"[REDACTED]"`, + `"FOO":"[REDACTED]"`, + `"X-Custom":"[REDACTED]"`, + `"server_name":"prom-prod"`, + } { + if !strings.Contains(got, want) { + t.Errorf("sanitizeBody(%q) = %q; want to contain %q", input, got, want) + } + } +} + func TestMakeRequestLogsRedactedBody(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/mcp_servers.go b/mcp_servers.go new file mode 100644 index 0000000..42394c9 --- /dev/null +++ b/mcp_servers.go @@ -0,0 +1,71 @@ +package flashduty + +import ( + "context" + "fmt" + "net/http" +) + +// CreateMCPServerInput is the payload for POST /safari/mcp/server/create. +// Transport must be one of "stdio", "sse", or "streamable-http". Fields are +// conditionally required by the backend depending on Transport: stdio uses +// Command/Args/Env; sse and streamable-http use URL/Headers. ConnectTimeout +// and CallTimeout are in seconds. +// +// TeamID is always serialized (no omitempty) because 0 is a meaningful +// sentinel — it explicitly requests account scope, distinct from "field +// omitted". Callers must set it deliberately: 0 = account scope; >0 = team scope. +type CreateMCPServerInput struct { + ServerName string `json:"server_name"` + Description string `json:"description"` + Transport string `json:"transport"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` + URL string `json:"url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + ConnectTimeout int `json:"connect_timeout,omitempty"` + CallTimeout int `json:"call_timeout,omitempty"` + Status string `json:"status,omitempty"` + TeamID int64 `json:"team_id"` +} + +// CreateMCPServerOutput is the unwrapped data block returned by +// POST /safari/mcp/server/create. +type CreateMCPServerOutput struct { + ServerID string `json:"server_id"` + Status string `json:"status"` +} + +// CreateMCPServer registers a new MCP server with Flashduty. +func (c *Client) CreateMCPServer(ctx context.Context, input *CreateMCPServerInput) (*CreateMCPServerOutput, error) { + if input == nil { + return nil, fmt.Errorf("create MCP server input is required") + } + + resp, err := c.makeRequest(ctx, "POST", "/safari/mcp/server/create", input) + if err != nil { + return nil, fmt.Errorf("failed to create MCP server: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, handleAPIError(c.logger, resp) + } + + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *CreateMCPServerOutput `json:"data,omitempty"` + } + if err := parseResponse(c.logger, resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return nil, result.Error + } + if result.Data == nil { + return nil, fmt.Errorf("create MCP server returned empty data") + } + + return result.Data, nil +} diff --git a/mcp_servers_test.go b/mcp_servers_test.go new file mode 100644 index 0000000..653f677 --- /dev/null +++ b/mcp_servers_test.go @@ -0,0 +1,134 @@ +package flashduty + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCreateMCPServer_HappyPath(t *testing.T) { + t.Parallel() + + var captured CreateMCPServerInput + var capturedMethod string + var capturedPath string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedMethod = r.Method + capturedPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "server_id": "mcp_abc123", + "status": "enabled", + }, + }) + })) + defer ts.Close() + + client, err := NewClient("test-key", WithBaseURL(ts.URL)) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + out, err := client.CreateMCPServer(context.Background(), &CreateMCPServerInput{ + ServerName: "prom-prod", + Description: "Prometheus prod MCP", + Transport: "sse", + URL: "https://prom.example/mcp", + Headers: map[string]string{"Authorization": "Bearer secret"}, + TeamID: 0, + }) + if err != nil { + t.Fatalf("CreateMCPServer error: %v", err) + } + + if capturedMethod != http.MethodPost { + t.Errorf("HTTP method = %q, want POST", capturedMethod) + } + if capturedPath != "/safari/mcp/server/create" { + t.Errorf("path = %q, want /safari/mcp/server/create", capturedPath) + } + if captured.ServerName != "prom-prod" { + t.Errorf("server_name = %q, want prom-prod", captured.ServerName) + } + if captured.Transport != "sse" { + t.Errorf("transport = %q, want sse", captured.Transport) + } + if captured.TeamID != 0 { + t.Errorf("team_id = %d, want 0", captured.TeamID) + } + + if out.ServerID != "mcp_abc123" { + t.Errorf("server_id = %q, want mcp_abc123", out.ServerID) + } + if out.Status != "enabled" { + t.Errorf("status = %q, want enabled", out.Status) + } +} + +func TestCreateMCPServer_APIError(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": "InvalidArgument", + "message": "transport must be one of stdio, sse, streamable-http", + }, + }) + })) + defer ts.Close() + + client, err := NewClient("test-key", WithBaseURL(ts.URL)) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + _, err = client.CreateMCPServer(context.Background(), &CreateMCPServerInput{ + ServerName: "bad", + Transport: "carrier-pigeon", + }) + if err == nil { + t.Fatal("expected error, got nil") + } + // Assert SDK unwraps the response envelope rather than just wrapping the + // raw body — a generic fmt.Errorf would also pass a substring check. + var de *DutyError + if !errors.As(err, &de) { + t.Fatalf("want *DutyError, got %T: %v", err, err) + } + if de.Code != "InvalidArgument" { + t.Fatalf("want code InvalidArgument, got %q", de.Code) + } +} + +func TestCreateMCPServer_HTTPError(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":{"code":"Internal","message":"boom"}}`)) + })) + defer ts.Close() + + client, err := NewClient("test-key", WithBaseURL(ts.URL)) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + _, err = client.CreateMCPServer(context.Background(), &CreateMCPServerInput{ + ServerName: "x", + Transport: "sse", + }) + if err == nil { + t.Fatal("expected error, got nil") + } +}