Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down
27 changes: 27 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
71 changes: 71 additions & 0 deletions mcp_servers.go
Original file line number Diff line number Diff line change
@@ -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
}
134 changes: 134 additions & 0 deletions mcp_servers_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}