From 3ab85b48ba2f1c465c6c9283f6348c5533a24caa Mon Sep 17 00:00:00 2001 From: Qasim Date: Tue, 12 May 2026 09:29:29 -0400 Subject: [PATCH] TW-4925: MCP proxy normalizes list_events and availability arguments before forwarding LLMs send integer start/end for list_events (schema expects strings) and unaligned timestamps for availability (API requires 5-min multiples). The proxy now coerces types and rounds timestamps transparently. --- AGENTS.md | 254 +--- internal/adapters/mcp/proxy.go | 121 ++ .../adapters/mcp/proxy_e2e_fixtures_test.go | 12 + internal/adapters/mcp/proxy_e2e_test.go | 416 ++++++ internal/adapters/mcp/proxy_normalize_test.go | 1227 +++++++++++++++++ 5 files changed, 1835 insertions(+), 195 deletions(-) create mode 100644 internal/adapters/mcp/proxy_normalize_test.go diff --git a/AGENTS.md b/AGENTS.md index 6173961..5a9f543 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,124 +1,28 @@ # AI Coding Agent Guidelines -Quick reference for AI coding agents (Cursor, Copilot, Windsurf, etc.) working on this Go CLI codebase. +For AI coding agents (Cursor, Copilot, Windsurf, Codex, etc.) working on this codebase. -## Build, Lint, Test Commands +**Read these first — they are the source of truth:** +- `CLAUDE.md` — Working principles, critical rules, learnings from past mistakes +- `docs/ARCHITECTURE.md` — Hexagonal architecture, project structure, package inventory +- `docs/DEVELOPMENT.md` — Build, test, lint, and CI commands (`make ci`, `make ci-full`, etc.) +- `docs/COMMANDS.md` — CLI command reference +- `.claude/rules/go-quality.md` — Go style, imports, error handling, modern patterns +- `.claude/rules/testing.md` — Test organization, coverage targets, rate limiting -### Quick Validation (Use Before Commits) -```bash -make ci # Format, vet, lint, unit tests, race detection, security, vuln, build -make ci-full # Complete CI: ci + integration tests + cleanup -``` - -### Running Tests -```bash -# All unit tests -make test-unit - -# Specific package -make test-pkg PKG=email - -# Single test by name -go test ./internal/cli/email/... -v -run TestSpecificName +Everything below supplements those docs with quick-reference examples. If anything here conflicts with the above, the above wins. -# With race detection -go test ./internal/cli/email/... -v -race -run TestSpecificName +--- -# Integration tests (requires NYLAS_API_KEY, NYLAS_GRANT_ID) -make test-integration -``` - -### Build -```bash -make build # Build binary to bin/nylas -make install # Install to GOPATH/bin -``` +## Quick Reference: Shared Helpers -## Code Style - -### Go Version -- **Go 1.24.2** - Use modern features: - - `any` instead of `interface{}` - - `slices` and `maps` packages instead of manual loops - - Generic functions where appropriate - -### Imports (Ordered Groups) -```go -import ( - "context" // 1. Standard library - "fmt" - - "github.com/spf13/cobra" // 2. External packages - - "github.com/nylas/cli/internal/ports" // 3. Internal packages -) -``` +Don't create package-local wrappers — use these directly: -### Error Handling ```go -// Always wrap errors with context -if err != nil { - return fmt.Errorf("failed to fetch emails: %w", err) -} - -// Check errors immediately, don't defer -resp, err := client.Do(req) -if err != nil { - return err -} -defer resp.Body.Close() -``` - -### Testing -```go -// Always use table-driven tests with t.Run() -func TestFormatSize(t *testing.T) { - tests := []struct { - name string - input int64 - expected string - }{ - {"zero bytes", 0, "0 B"}, - {"kilobytes", 1024, "1.0 KB"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := FormatSize(tt.input) - if got != tt.expected { - t.Errorf("got %q, want %q", got, tt.expected) - } - }) - } -} -``` - -### File Size Limits -- **Ideal:** ≤500 lines per file -- **Maximum:** ≤600 lines per file -- Split large files by responsibility (helpers, types, handlers) - -## Project Patterns - -### Use Shared Helpers (Don't Create Duplicates) - -```go -// CLI client - use directly, no package wrappers +// CLI client client := common.GetNylasClient() - -// Grant ID from args grantID := common.GetGrantID(args) -// Output formatting -common.PrintSuccess("Email sent successfully") -common.PrintError("Failed to send email", err) -common.FormatSize(bytes) // "1.5 MB" -common.FormatTimeAgo(time) // "2 hours ago" -common.PrintJSON(data) // Pretty-print JSON - -// Structured output (use in list commands) -out := common.GetOutputWriter(cmd) // Gets writer based on --json/--yaml/--quiet -out.Write(data) // Outputs in correct format - // Client helpers (reduce boilerplate) common.WithClient(args, func(ctx, client, grantID) (T, error) { return client.DoSomething(ctx, grantID) @@ -127,109 +31,69 @@ common.WithClientNoGrant(func(ctx, client) (T, error) { return client.DoSomething(ctx) }) -// Flag helpers (use instead of inline flag definitions) +// Output +common.PrintSuccess("Email sent successfully") +common.PrintError("Failed to send email", err) +common.FormatSize(bytes) // "1.5 MB" +common.FormatTimeAgo(time) // "2 hours ago" +common.PrintJSON(data) // Pretty-print JSON +out := common.GetOutputWriter(cmd) // --json/--yaml/--quiet + +// Flags common.AddJSONFlag(cmd, &jsonOutput) // --json common.AddLimitFlag(cmd, &limit, 25) // --limit/-n common.AddYesFlag(cmd, &yes) // --yes/-y common.AddFormatFlag(cmd, &format) // --format/-f -common.AddIDFlag(cmd, &showID) // --id -common.AddPageTokenFlag(cmd, &token) // --page-token -// Validation helpers (use instead of inline checks) +// Validation common.ValidateRequired("event ID", eventID) common.ValidateRequiredFlag("--to", toEmail) -common.ValidateRequiredArg(args, "message ID") -common.ValidateURL("webhook URL", webhookURL) common.ValidateEmail("recipient", email) +common.ValidateURL("webhook URL", webhookURL) common.ValidateOneOf("status", status, []string{"pending", "active"}) -common.ValidateAtLeastOne("update field", url, description, status) -// HTTP handlers (in adapters) +// HTTP (in adapters) httputil.WriteJSON(w, http.StatusOK, data) body, err := httputil.LimitedBody(r, maxSize) -httputil.DecodeJSON(r, &target) -``` -### AI Client Helpers -```go -// In adapters/ai/ - use shared base_client.go helpers +// AI (in adapters/ai/) ConvertMessagesToMaps(messages) ConvertToolsOpenAIFormat(tools) -FallbackStreamChat(ctx, messages, opts) ``` -## Architecture +--- -Hexagonal architecture with three layers: +## Quick Reference: Adding a New Feature -``` -CLI (internal/cli/) - ↓ calls -Ports (internal/ports/) - Interfaces - ↓ implemented by -Adapters (internal/adapters/) - Implementations -``` +1. **Domain:** `internal/domain/.go` — define types +2. **Port:** `internal/ports/nylas.go` — add interface methods +3. **Adapter:** `internal/adapters/nylas/.go` — implement +4. **Mock:** `internal/adapters/nylas/mock.go` — add mock methods +5. **CLI:** `internal/cli//` — add commands +6. **Register:** `cmd/nylas/main.go` — wire command +7. **Tests:** unit + integration tests +8. **Docs:** update `docs/COMMANDS.md` + +--- + +## Quick Reference: Credential Storage + +Credentials stored in system keyring (service: `"nylas"`). + +| Key | Description | +|-----|-------------| +| `client_id` | Nylas Application/Client ID | +| `api_key` | Nylas API key (Bearer auth) | +| `client_secret` | Provider OAuth client secret (optional) | +| `org_id` | Nylas Organization ID | +| `grants` | JSON array of grant info (ID, email, provider) | +| `default_grant` | Default grant ID for CLI operations | +| `grant_token_` | Per-grant access tokens | + +Key files: `internal/ports/secrets.go`, `internal/adapters/keyring/keyring.go`, `internal/adapters/keyring/grants.go` + +Fallback: set `NYLAS_DISABLE_KEYRING=true` for encrypted file store (`~/.config/nylas/`). + +--- -### Key Packages -| Package | Purpose | -|---------|---------| -| `internal/domain/` | Domain types (Email, Calendar, etc.) | -| `internal/ports/nylas.go` | Main NylasClient interface | -| `internal/ports/output.go` | OutputWriter interface | -| `internal/adapters/nylas/` | Nylas API client implementation | -| `internal/adapters/output/` | Table, JSON, YAML, Quiet formatters | -| `internal/httputil/` | HTTP response helpers | -| `internal/cli/common/` | Shared CLI helpers | -| `internal/air/` | Web email client | - -### Adding a New Feature -1. **Domain:** `internal/domain/.go` - Define types -2. **Port:** `internal/ports/nylas.go` - Add interface methods -3. **Adapter:** `internal/adapters/nylas/.go` - Implement -4. **Mock:** `internal/adapters/nylas/mock.go` - Add mock methods -5. **CLI:** `internal/cli//` - Add commands -6. **Register:** `cmd/nylas/main.go` - Wire command -7. **Tests:** Unit + integration tests -8. **Docs:** Update `docs/COMMANDS.md` - -## Do Not Modify -- `.env*`, `**/secrets/**` - Contains secrets -- `*.pem`, `*.key` - Certificates -- `go.sum` - Auto-generated -- `.git/`, `vendor/` - Managed externally - -## Credential Storage (Keyring) - -Credentials are stored securely in the system keyring under service name `"nylas"`. - -### Keys Stored -| Key | Constant | Description | -|-----|----------|-------------| -| `client_id` | `ports.KeyClientID` | Nylas Application/Client ID (auto-detected or manual) | -| `api_key` | `ports.KeyAPIKey` | Nylas API key (required, used for Bearer auth) | -| `client_secret` | `ports.KeyClientSecret` | Provider OAuth client secret (Google/Microsoft), optional | -| `org_id` | `ports.KeyOrgID` | Nylas Organization ID (auto-detected) | -| `grants` | `grantsKey` | JSON array of grant info (ID, email, provider) | -| `default_grant` | `defaultGrantKey` | Default grant ID for CLI operations | -| `grant_token_` | `ports.GrantTokenKey()` | Per-grant access tokens | - -### Key Files -| File | Purpose | -|------|---------| -| `internal/ports/secrets.go` | Key constants (`KeyClientID`, `KeyAPIKey`, etc.) | -| `internal/adapters/keyring/keyring.go` | System keyring implementation | -| `internal/adapters/keyring/grants.go` | Grant storage (`grants`, `default_grant` keys) | -| `internal/app/auth/config.go` | `SetupConfig()` saves credentials | - -### Platform Storage -- **Linux**: Secret Service (GNOME Keyring, KWallet) -- **macOS**: Keychain -- **Windows**: Windows Credential Manager -- **Fallback**: Encrypted file store (`~/.config/nylas/`) - -### Environment Override -Set `NYLAS_DISABLE_KEYRING=true` to force encrypted file store (useful for testing). - -## API Reference -- **Nylas API v3 ONLY** - Never use v1/v2 -- Docs: https://developer.nylas.com/docs/api/v3/ +**Nylas API v3 ONLY** — never use v1/v2. diff --git a/internal/adapters/mcp/proxy.go b/internal/adapters/mcp/proxy.go index bc3caea..cde8ccd 100644 --- a/internal/adapters/mcp/proxy.go +++ b/internal/adapters/mcp/proxy.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "io" + "math" "net/http" "os" "strings" @@ -179,6 +180,9 @@ func (p *Proxy) forward(ctx context.Context, request []byte, parsed *rpcRequest) // Inject default grant into tool calls if not specified request = p.injectDefaultGrant(request, parsed) + // Normalize tool arguments (type coercion, timestamp rounding) + request = p.normalizeToolArguments(request, parsed) + req, err := http.NewRequestWithContext(ctx, "POST", p.endpoint, bytes.NewReader(request)) if err != nil { return nil, fmt.Errorf("creating request: %w", err) @@ -415,3 +419,120 @@ func (p *Proxy) injectDefaultGrant(request []byte, parsed *rpcRequest) []byte { return modified } + +// normalizeToolArguments fixes type mismatches and rounds timestamps before +// forwarding to the upstream server. LLMs frequently send integers where the +// schema expects strings, or send unaligned timestamps that the API rejects. +func (p *Proxy) normalizeToolArguments(request []byte, parsed *rpcRequest) []byte { + var req *rpcRequest + if parsed != nil { + req = parsed + } else { + var r rpcRequest + if err := json.Unmarshal(request, &r); err != nil { + return request + } + req = &r + } + + if req.Method != "tools/call" || req.Params.Arguments == nil { + return request + } + + var modified bool + + switch req.Params.Name { + case "list_events": + modified = normalizeListEventsArgs(req.Params.Arguments) + case "availability": + modified = normalizeAvailabilityArgs(req.Params.Arguments) + } + + if !modified { + return request + } + + out, err := json.Marshal(req) + if err != nil { + return request + } + return out +} + +// normalizeListEventsArgs coerces numeric start/end fields to strings inside +// get_all_query_parameters. The upstream schema expects string timestamps but +// LLMs naturally produce integers. +func normalizeListEventsArgs(args map[string]any) bool { + params, ok := args["get_all_query_parameters"].(map[string]any) + if !ok { + return false + } + + modified := false + for _, key := range []string{"start", "end"} { + if v, exists := params[key]; exists { + if num, ok := toInt64(v); ok { + params[key] = fmt.Sprintf("%d", num) + modified = true + } + } + } + return modified +} + +// normalizeAvailabilityArgs rounds start_time down and end_time up to the +// nearest 5-minute boundary. The Nylas API requires these to be multiples of +// 300 seconds. +func normalizeAvailabilityArgs(args map[string]any) bool { + req, ok := args["availability_request"].(map[string]any) + if !ok { + return false + } + + modified := false + if v, exists := req["start_time"]; exists { + if num, ok := toInt64(v); ok { + rounded := roundDown5Min(num) + if rounded != num { + req["start_time"] = rounded + modified = true + } + } + } + if v, exists := req["end_time"]; exists { + if num, ok := toInt64(v); ok { + rounded := roundUp5Min(num) + if rounded != num { + req["end_time"] = rounded + modified = true + } + } + } + return modified +} + +func roundDown5Min(epoch int64) int64 { + return (epoch / 300) * 300 +} + +func roundUp5Min(epoch int64) int64 { + return int64(math.Ceil(float64(epoch)/300)) * 300 +} + +// toInt64 extracts an integer from a JSON-decoded value. JSON numbers decode +// as float64 in map[string]any; this also handles explicit int/int64 values. +func toInt64(v any) (int64, bool) { + switch n := v.(type) { + case float64: + return int64(n), true + case int: + return int64(n), true + case int64: + return n, true + case json.Number: + i, err := n.Int64() + return i, err == nil + default: + return 0, false + } +} diff --git a/internal/adapters/mcp/proxy_e2e_fixtures_test.go b/internal/adapters/mcp/proxy_e2e_fixtures_test.go index 0b13f7f..ec41d74 100644 --- a/internal/adapters/mcp/proxy_e2e_fixtures_test.go +++ b/internal/adapters/mcp/proxy_e2e_fixtures_test.go @@ -51,6 +51,18 @@ const mockToolsListResponse = `{ "required": ["contact_id"] } }, + { + "name": "list_events", + "description": "List events in a calendar", + "inputSchema": { + "type": "object", + "properties": { + "grant_id": {"type": "string"}, + "get_all_query_parameters": {"type": "object"} + }, + "required": ["get_all_query_parameters"] + } + }, { "name": "create_event", "description": "Create a calendar event", diff --git a/internal/adapters/mcp/proxy_e2e_test.go b/internal/adapters/mcp/proxy_e2e_test.go index dccd6a0..e33777b 100644 --- a/internal/adapters/mcp/proxy_e2e_test.go +++ b/internal/adapters/mcp/proxy_e2e_test.go @@ -415,6 +415,422 @@ func TestE2E_ServerError(t *testing.T) { } } +// TestE2E_NormalizeListEventsArgs verifies the proxy coerces integer start/end +// to strings before forwarding list_events to the upstream server. +func TestE2E_NormalizeListEventsArgs(t *testing.T) { + t.Parallel() + + mock := &mockMCPServer{t: t} + server := httptest.NewServer(mock) + defer server.Close() + + proxy := NewProxy("test-api-key", "us") + proxy.endpoint = server.URL + proxy.SetDefaultGrant("grant-norm") + + ctx := t.Context() + + t.Run("integer_start_end_coerced_to_strings", func(t *testing.T) { + req := parseRPC(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":1747065600,"end":1747152000,"limit":5}}}}`) + _, err := proxy.forward(ctx, req.raw, req.parsed) + if err != nil { + t.Fatalf("forward failed: %v", err) + } + + params, ok := mock.lastArgs["get_all_query_parameters"].(map[string]any) + if !ok { + t.Fatal("expected get_all_query_parameters in forwarded args") + } + + start, ok := params["start"].(string) + if !ok { + t.Errorf("expected start to be string, got %T (%v)", params["start"], params["start"]) + } else if start != "1747065600" { + t.Errorf("start = %q, want %q", start, "1747065600") + } + + end, ok := params["end"].(string) + if !ok { + t.Errorf("expected end to be string, got %T (%v)", params["end"], params["end"]) + } else if end != "1747152000" { + t.Errorf("end = %q, want %q", end, "1747152000") + } + }) + + t.Run("string_start_end_preserved", func(t *testing.T) { + req := parseRPC(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":"1747065600","end":"1747152000","limit":5}}}}`) + _, err := proxy.forward(ctx, req.raw, req.parsed) + if err != nil { + t.Fatalf("forward failed: %v", err) + } + + params, ok := mock.lastArgs["get_all_query_parameters"].(map[string]any) + if !ok { + t.Fatal("expected get_all_query_parameters in forwarded args") + } + + if start, ok := params["start"].(string); !ok || start != "1747065600" { + t.Errorf("expected start string preserved, got %T %v", params["start"], params["start"]) + } + }) +} + +// TestE2E_NormalizeAvailabilityArgs verifies the proxy rounds availability +// timestamps to 5-minute boundaries before forwarding. +func TestE2E_NormalizeAvailabilityArgs(t *testing.T) { + t.Parallel() + + mock := &mockMCPServer{t: t} + server := httptest.NewServer(mock) + defer server.Close() + + proxy := NewProxy("test-api-key", "us") + proxy.endpoint = server.URL + + ctx := t.Context() + + t.Run("unaligned_timestamps_rounded", func(t *testing.T) { + req := parseRPC(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":1747065601,"end_time":1747065899,"duration_minutes":30,"interval_minutes":30,"participants":[{"email":"user@example.com"}]}}}}`) + _, err := proxy.forward(ctx, req.raw, req.parsed) + if err != nil { + t.Fatalf("forward failed: %v", err) + } + + avail, ok := mock.lastArgs["availability_request"].(map[string]any) + if !ok { + t.Fatal("expected availability_request in forwarded args") + } + + startTime, ok := avail["start_time"].(float64) + if !ok { + t.Fatalf("expected start_time to be float64, got %T", avail["start_time"]) + } + if startTime != 1747065600 { + t.Errorf("start_time = %v, want %v (rounded down)", startTime, 1747065600) + } + + endTime, ok := avail["end_time"].(float64) + if !ok { + t.Fatalf("expected end_time to be float64, got %T", avail["end_time"]) + } + if endTime != 1747065900 { + t.Errorf("end_time = %v, want %v (rounded up)", endTime, 1747065900) + } + }) + + t.Run("aligned_timestamps_unchanged", func(t *testing.T) { + req := parseRPC(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":1747065600,"end_time":1747066200,"duration_minutes":30,"interval_minutes":30,"participants":[{"email":"user@example.com"}]}}}}`) + _, err := proxy.forward(ctx, req.raw, req.parsed) + if err != nil { + t.Fatalf("forward failed: %v", err) + } + + avail, ok := mock.lastArgs["availability_request"].(map[string]any) + if !ok { + t.Fatal("expected availability_request in forwarded args") + } + + if startTime, ok := avail["start_time"].(float64); !ok || startTime != 1747065600 { + t.Errorf("start_time = %v, want %v (should be unchanged)", avail["start_time"], 1747065600) + } + if endTime, ok := avail["end_time"].(float64); !ok || endTime != 1747066200 { + t.Errorf("end_time = %v, want %v (should be unchanged)", avail["end_time"], 1747066200) + } + }) +} + +// TestE2E_NormalizeListEvents_GrantInjectionCombined verifies grant injection and +// list_events normalization work together through the full forward path. +func TestE2E_NormalizeListEvents_GrantInjectionCombined(t *testing.T) { + t.Parallel() + + mock := &mockMCPServer{t: t} + server := httptest.NewServer(mock) + defer server.Close() + + proxy := NewProxy("test-api-key", "us") + proxy.endpoint = server.URL + proxy.SetDefaultGrant("combined-grant-xyz") + + ctx := t.Context() + + // No grant_id in request + integer start/end — both should be fixed + req := parseRPC(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":1747065600,"end":1747152000,"limit":3}}}}`) + _, err := proxy.forward(ctx, req.raw, req.parsed) + if err != nil { + t.Fatalf("forward failed: %v", err) + } + + // Verify grant was injected + if mock.receivedGrant != "combined-grant-xyz" { + t.Errorf("grant_id = %q, want 'combined-grant-xyz'", mock.receivedGrant) + } + + // Verify start/end were coerced to strings + params, ok := mock.lastArgs["get_all_query_parameters"].(map[string]any) + if !ok { + t.Fatal("expected get_all_query_parameters") + } + if start, ok := params["start"].(string); !ok || start != "1747065600" { + t.Errorf("start = %v (%T), want string '1747065600'", params["start"], params["start"]) + } + if end, ok := params["end"].(string); !ok || end != "1747152000" { + t.Errorf("end = %v (%T), want string '1747152000'", params["end"], params["end"]) + } + + // Verify limit was NOT converted to string + if limit, ok := params["limit"].(float64); !ok || limit != 3 { + t.Errorf("limit = %v (%T), want float64 3", params["limit"], params["limit"]) + } +} + +// TestE2E_NormalizeListEvents_ExplicitGrantPreserved verifies that explicit +// grant_id is preserved while start/end are still normalized. +func TestE2E_NormalizeListEvents_ExplicitGrantPreserved(t *testing.T) { + t.Parallel() + + mock := &mockMCPServer{t: t} + server := httptest.NewServer(mock) + defer server.Close() + + proxy := NewProxy("test-api-key", "us") + proxy.endpoint = server.URL + proxy.SetDefaultGrant("default-grant") + + ctx := t.Context() + + req := parseRPC(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"grant_id":"user-explicit-grant","get_all_query_parameters":{"calendar_id":"primary","start":1747065600,"end":1747152000}}}}`) + _, err := proxy.forward(ctx, req.raw, req.parsed) + if err != nil { + t.Fatalf("forward failed: %v", err) + } + + // User's explicit grant should be preserved, not overridden + if mock.receivedGrant != "user-explicit-grant" { + t.Errorf("grant_id = %q, want 'user-explicit-grant'", mock.receivedGrant) + } + + // start/end should still be normalized + params := mock.lastArgs["get_all_query_parameters"].(map[string]any) + if _, ok := params["start"].(string); !ok { + t.Errorf("start should be string even with explicit grant, got %T", params["start"]) + } +} + +// TestE2E_NormalizeAvailability_WithParticipants verifies availability rounding +// doesn't affect participant data. +func TestE2E_NormalizeAvailability_WithParticipants(t *testing.T) { + t.Parallel() + + mock := &mockMCPServer{t: t} + server := httptest.NewServer(mock) + defer server.Close() + + proxy := NewProxy("test-api-key", "us") + proxy.endpoint = server.URL + + ctx := t.Context() + + req := parseRPC(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":1747065601,"end_time":1747065899,"duration_minutes":30,"interval_minutes":15,"participants":[{"email":"alice@example.com"},{"email":"bob@example.com","open_hours":[{"days":[1,2,3,4,5],"start":"09:00","end":"17:00","timezone":"America/New_York","exdates":[]}]}]}}}}`) + _, err := proxy.forward(ctx, req.raw, req.parsed) + if err != nil { + t.Fatalf("forward failed: %v", err) + } + + avail := mock.lastArgs["availability_request"].(map[string]any) + + // Timestamps should be rounded + if st, ok := avail["start_time"].(float64); !ok || st != 1747065600 { + t.Errorf("start_time = %v, want 1747065600", avail["start_time"]) + } + if et, ok := avail["end_time"].(float64); !ok || et != 1747065900 { + t.Errorf("end_time = %v, want 1747065900", avail["end_time"]) + } + + // Participants should be fully preserved + participants, ok := avail["participants"].([]any) + if !ok || len(participants) != 2 { + t.Fatalf("expected 2 participants, got %v", avail["participants"]) + } + + p1 := participants[0].(map[string]any) + if p1["email"] != "alice@example.com" { + t.Errorf("first participant email = %v", p1["email"]) + } + + p2 := participants[1].(map[string]any) + if p2["email"] != "bob@example.com" { + t.Errorf("second participant email = %v", p2["email"]) + } + + // open_hours on participant 2 should be preserved + openHours, ok := p2["open_hours"].([]any) + if !ok || len(openHours) != 1 { + t.Fatalf("expected 1 open_hours entry on p2, got %v", p2["open_hours"]) + } + oh := openHours[0].(map[string]any) + if oh["timezone"] != "America/New_York" { + t.Errorf("open_hours timezone = %v, want America/New_York", oh["timezone"]) + } + + // duration and interval should be preserved + if dur, ok := avail["duration_minutes"].(float64); !ok || dur != 30 { + t.Errorf("duration_minutes = %v, want 30", avail["duration_minutes"]) + } + if iv, ok := avail["interval_minutes"].(float64); !ok || iv != 15 { + t.Errorf("interval_minutes = %v, want 15", avail["interval_minutes"]) + } +} + +// TestE2E_NormalizationAfterDiscovery verifies that normalization works correctly +// after tools/list dynamic discovery has populated grantTools. +func TestE2E_NormalizationAfterDiscovery(t *testing.T) { + t.Parallel() + + mock := &mockMCPServer{t: t} + server := httptest.NewServer(mock) + defer server.Close() + + proxy := NewProxy("test-api-key", "us") + proxy.endpoint = server.URL + proxy.SetDefaultGrant("disc-grant") + + ctx := t.Context() + + // Step 1: trigger discovery + toolsReq := parseRPC(`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`) + _, err := proxy.forward(ctx, toolsReq.raw, toolsReq.parsed) + if err != nil { + t.Fatalf("tools/list failed: %v", err) + } + + // Step 2: list_events with integer start/end — should normalize + inject grant + eventsReq := parseRPC(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":1747065600,"end":1747152000}}}}`) + _, err = proxy.forward(ctx, eventsReq.raw, eventsReq.parsed) + if err != nil { + t.Fatalf("list_events failed: %v", err) + } + + if mock.receivedGrant != "disc-grant" { + t.Errorf("post-discovery grant = %q, want 'disc-grant'", mock.receivedGrant) + } + params := mock.lastArgs["get_all_query_parameters"].(map[string]any) + if _, ok := params["start"].(string); !ok { + t.Errorf("post-discovery start not coerced, got %T", params["start"]) + } +} + +// TestE2E_NormalizationDoesNotAffectOtherTools verifies that tools like +// list_messages, list_threads, etc. are forwarded without modification even +// when they contain get_all_query_parameters. +func TestE2E_NormalizationDoesNotAffectOtherTools(t *testing.T) { + t.Parallel() + + mock := &mockMCPServer{t: t} + server := httptest.NewServer(mock) + defer server.Close() + + proxy := NewProxy("test-api-key", "us") + proxy.endpoint = server.URL + proxy.SetDefaultGrant("other-grant") + + ctx := t.Context() + + tests := []struct { + name string + input string + check func(t *testing.T) + }{ + { + name: "list_messages numeric fields unchanged", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_messages","arguments":{"get_all_query_parameters":{"limit":10,"received_after":1747065600}}}}`, + check: func(t *testing.T) { + params := mock.lastArgs["get_all_query_parameters"].(map[string]any) + // received_after should stay numeric, not converted to string + if _, ok := params["received_after"].(float64); !ok { + t.Errorf("received_after should remain numeric, got %T", params["received_after"]) + } + }, + }, + { + name: "list_threads numeric fields unchanged", + input: `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_threads","arguments":{"get_all_query_parameters":{"limit":5}}}}`, + check: func(t *testing.T) { + params := mock.lastArgs["get_all_query_parameters"].(map[string]any) + if limit, ok := params["limit"].(float64); !ok || limit != 5 { + t.Errorf("limit = %v, want 5", params["limit"]) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := parseRPC(tt.input) + _, err := proxy.forward(ctx, req.raw, req.parsed) + if err != nil { + t.Fatalf("forward failed: %v", err) + } + tt.check(t) + }) + } +} + +// TestE2E_MultipleSequentialNormalizations verifies normalization works correctly +// across multiple sequential calls on the same proxy instance. +func TestE2E_MultipleSequentialNormalizations(t *testing.T) { + t.Parallel() + + mock := &mockMCPServer{t: t} + server := httptest.NewServer(mock) + defer server.Close() + + proxy := NewProxy("test-api-key", "us") + proxy.endpoint = server.URL + proxy.SetDefaultGrant("seq-grant") + + ctx := t.Context() + + // Call 1: list_events + req1 := parseRPC(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":100,"end":200}}}}`) + _, err := proxy.forward(ctx, req1.raw, req1.parsed) + if err != nil { + t.Fatalf("call 1 failed: %v", err) + } + params1 := mock.lastArgs["get_all_query_parameters"].(map[string]any) + if params1["start"] != "100" { + t.Errorf("call 1 start = %v, want '100'", params1["start"]) + } + + // Call 2: availability + req2 := parseRPC(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":301,"end_time":599,"duration_minutes":30,"participants":[]}}}}`) + _, err = proxy.forward(ctx, req2.raw, req2.parsed) + if err != nil { + t.Fatalf("call 2 failed: %v", err) + } + avail := mock.lastArgs["availability_request"].(map[string]any) + if st, ok := avail["start_time"].(float64); !ok || st != 300 { + t.Errorf("call 2 start_time = %v, want 300", avail["start_time"]) + } + if et, ok := avail["end_time"].(float64); !ok || et != 600 { + t.Errorf("call 2 end_time = %v, want 600", avail["end_time"]) + } + + // Call 3: list_events again with different values + req3 := parseRPC(`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":999,"end":1000}}}}`) + _, err = proxy.forward(ctx, req3.raw, req3.parsed) + if err != nil { + t.Fatalf("call 3 failed: %v", err) + } + params3 := mock.lastArgs["get_all_query_parameters"].(map[string]any) + if params3["start"] != "999" { + t.Errorf("call 3 start = %v, want '999'", params3["start"]) + } + if params3["end"] != "1000" { + t.Errorf("call 3 end = %v, want '1000'", params3["end"]) + } +} + // --- helpers --- type parsedRPC struct { diff --git a/internal/adapters/mcp/proxy_normalize_test.go b/internal/adapters/mcp/proxy_normalize_test.go new file mode 100644 index 0000000..e0da3be --- /dev/null +++ b/internal/adapters/mcp/proxy_normalize_test.go @@ -0,0 +1,1227 @@ +package mcp + +import ( + "encoding/json" + "sync" + "testing" +) + +func TestProxy_normalizeToolArguments_ListEvents(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + tests := []struct { + name string + input string + wantStart string + wantEnd string + }{ + { + name: "coerces integer start and end to strings", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":1747065600,"end":1747152000,"limit":5}}}}`, + wantStart: "1747065600", + wantEnd: "1747152000", + }, + { + name: "coerces float start and end to strings", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":1747065600.0,"end":1747152000.0,"limit":5}}}}`, + wantStart: "1747065600", + wantEnd: "1747152000", + }, + { + name: "preserves string start and end unchanged", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":"1747065600","end":"1747152000","limit":5}}}}`, + wantStart: "1747065600", + wantEnd: "1747152000", + }, + { + name: "handles only start present (no end)", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":1747065600,"limit":5}}}}`, + wantStart: "1747065600", + wantEnd: "", + }, + { + name: "handles only end present (no start)", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","end":1747152000,"limit":5}}}}`, + wantStart: "", + wantEnd: "1747152000", + }, + { + name: "mixed types: integer start with string end", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":1747065600,"end":"1747152000"}}}}`, + wantStart: "1747065600", + wantEnd: "1747152000", + }, + { + name: "mixed types: string start with integer end", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":"1747065600","end":1747152000}}}}`, + wantStart: "1747065600", + wantEnd: "1747152000", + }, + { + name: "zero epoch value coerced to string", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":0,"end":86400}}}}`, + wantStart: "0", + wantEnd: "86400", + }, + { + name: "far future timestamps (year 2050)", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":2524608000,"end":2524694400}}}}`, + wantStart: "2524608000", + wantEnd: "2524694400", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := proxy.normalizeToolArguments([]byte(tt.input), nil) + + var parsed rpcRequest + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + params, ok := parsed.Params.Arguments["get_all_query_parameters"].(map[string]any) + if !ok { + t.Fatal("expected get_all_query_parameters to be a map") + } + + if tt.wantStart != "" { + start, ok := params["start"].(string) + if !ok { + t.Errorf("expected start to be string, got %T (%v)", params["start"], params["start"]) + } else if start != tt.wantStart { + t.Errorf("start = %q, want %q", start, tt.wantStart) + } + } + + if tt.wantEnd != "" { + end, ok := params["end"].(string) + if !ok { + t.Errorf("expected end to be string, got %T (%v)", params["end"], params["end"]) + } else if end != tt.wantEnd { + t.Errorf("end = %q, want %q", end, tt.wantEnd) + } + } + }) + } +} + +func TestProxy_normalizeToolArguments_ListEvents_OtherFieldsUntouched(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + input := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"grant_id":"abc","get_all_query_parameters":{"calendar_id":"primary","start":1747065600,"end":1747152000,"limit":5}}}}` + result := proxy.normalizeToolArguments([]byte(input), nil) + + var parsed rpcRequest + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + params := parsed.Params.Arguments["get_all_query_parameters"].(map[string]any) + + // limit should still be a number, not coerced to string + limit, ok := params["limit"].(float64) + if !ok { + t.Errorf("expected limit to remain numeric, got %T (%v)", params["limit"], params["limit"]) + } else if limit != 5 { + t.Errorf("limit = %v, want 5", limit) + } + + // calendar_id should still be a string + calID, ok := params["calendar_id"].(string) + if !ok || calID != "primary" { + t.Errorf("calendar_id = %v, want 'primary'", params["calendar_id"]) + } + + // grant_id should be untouched at the top level + grantID, ok := parsed.Params.Arguments["grant_id"].(string) + if !ok || grantID != "abc" { + t.Errorf("grant_id = %v, want 'abc'", parsed.Params.Arguments["grant_id"]) + } +} + +func TestProxy_normalizeToolArguments_ListEvents_PreParsed(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + raw := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":1747065600,"end":1747152000}}}}`) + var req rpcRequest + if err := json.Unmarshal(raw, &req); err != nil { + t.Fatal(err) + } + + result := proxy.normalizeToolArguments(raw, &req) + + var parsed rpcRequest + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("failed to parse: %v", err) + } + + params := parsed.Params.Arguments["get_all_query_parameters"].(map[string]any) + if _, ok := params["start"].(string); !ok { + t.Errorf("expected start to be string when using pre-parsed request, got %T", params["start"]) + } +} + +func TestProxy_normalizeToolArguments_Availability(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + tests := []struct { + name string + input string + wantStart float64 + wantEnd float64 + }{ + { + name: "rounds unaligned timestamps to 5-min boundaries", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":1747065601,"end_time":1747065899,"duration_minutes":30,"participants":[]}}}}`, + wantStart: 1747065600, // floor(1747065601/300)*300 + wantEnd: 1747065900, // ceil(1747065899/300)*300 + }, + { + name: "preserves already-aligned timestamps", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":1747065600,"end_time":1747066200,"duration_minutes":30,"participants":[]}}}}`, + wantStart: 1747065600, + wantEnd: 1747066200, + }, + { + name: "rounds start down and end up for same unaligned value", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":1747065700,"end_time":1747065700,"duration_minutes":30,"participants":[]}}}}`, + wantStart: 1747065600, + wantEnd: 1747065900, + }, + { + name: "handles timestamps at exact 5-min boundary", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":1747065900,"end_time":1747065900,"duration_minutes":30,"participants":[]}}}}`, + wantStart: 1747065900, + wantEnd: 1747065900, + }, + { + name: "rounds 1 second past boundary", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":301,"end_time":301,"duration_minutes":30,"participants":[]}}}}`, + wantStart: 300, + wantEnd: 600, + }, + { + name: "rounds 1 second before boundary", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":299,"end_time":299,"duration_minutes":30,"participants":[]}}}}`, + wantStart: 0, + wantEnd: 300, + }, + { + name: "zero timestamps stay at zero", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":0,"end_time":0,"duration_minutes":30,"participants":[]}}}}`, + wantStart: 0, + wantEnd: 0, + }, + { + name: "large realistic timestamps rounded", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":1778588147,"end_time":1778674547,"duration_minutes":30,"participants":[]}}}}`, + wantStart: 1778588100, + wantEnd: 1778674800, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := proxy.normalizeToolArguments([]byte(tt.input), nil) + + var parsed rpcRequest + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + req, ok := parsed.Params.Arguments["availability_request"].(map[string]any) + if !ok { + t.Fatal("expected availability_request to be a map") + } + + gotStart, ok := req["start_time"].(float64) + if !ok { + t.Fatalf("expected start_time to be float64, got %T", req["start_time"]) + } + if gotStart != tt.wantStart { + t.Errorf("start_time = %v, want %v", gotStart, tt.wantStart) + } + + gotEnd, ok := req["end_time"].(float64) + if !ok { + t.Fatalf("expected end_time to be float64, got %T", req["end_time"]) + } + if gotEnd != tt.wantEnd { + t.Errorf("end_time = %v, want %v", gotEnd, tt.wantEnd) + } + }) + } +} + +func TestProxy_normalizeToolArguments_Availability_PartialFields(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + tests := []struct { + name string + input string + checkStart bool + checkEnd bool + wantStart float64 + wantEnd float64 + }{ + { + name: "only start_time present", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":1747065601,"duration_minutes":30,"participants":[]}}}}`, + checkStart: true, + wantStart: 1747065600, + }, + { + name: "only end_time present", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"end_time":1747065601,"duration_minutes":30,"participants":[]}}}}`, + checkEnd: true, + wantEnd: 1747065900, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := proxy.normalizeToolArguments([]byte(tt.input), nil) + + var parsed rpcRequest + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + req := parsed.Params.Arguments["availability_request"].(map[string]any) + + if tt.checkStart { + gotStart, ok := req["start_time"].(float64) + if !ok { + t.Fatalf("expected start_time float64, got %T", req["start_time"]) + } + if gotStart != tt.wantStart { + t.Errorf("start_time = %v, want %v", gotStart, tt.wantStart) + } + } + if tt.checkEnd { + gotEnd, ok := req["end_time"].(float64) + if !ok { + t.Fatalf("expected end_time float64, got %T", req["end_time"]) + } + if gotEnd != tt.wantEnd { + t.Errorf("end_time = %v, want %v", gotEnd, tt.wantEnd) + } + } + }) + } +} + +func TestProxy_normalizeToolArguments_Availability_OtherFieldsUntouched(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + input := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":1747065601,"end_time":1747065899,"duration_minutes":30,"interval_minutes":15,"participants":[{"email":"user@example.com"}]}}}}` + result := proxy.normalizeToolArguments([]byte(input), nil) + + var parsed rpcRequest + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("failed to parse: %v", err) + } + + req := parsed.Params.Arguments["availability_request"].(map[string]any) + + // duration_minutes should be untouched + if dur, ok := req["duration_minutes"].(float64); !ok || dur != 30 { + t.Errorf("duration_minutes = %v, want 30", req["duration_minutes"]) + } + + // interval_minutes should be untouched + if iv, ok := req["interval_minutes"].(float64); !ok || iv != 15 { + t.Errorf("interval_minutes = %v, want 15", req["interval_minutes"]) + } + + // participants should be untouched + participants, ok := req["participants"].([]any) + if !ok || len(participants) != 1 { + t.Errorf("participants should have 1 entry, got %v", req["participants"]) + } +} + +func TestProxy_normalizeToolArguments_Availability_StringTimestampsIgnored(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + // String timestamps should not be modified (only numeric ones are rounded) + input := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":"not_a_number","end_time":"also_not","duration_minutes":30,"participants":[]}}}}` + result := proxy.normalizeToolArguments([]byte(input), nil) + + // Should be unmodified since toInt64 returns false for strings + if string(result) != input { + t.Errorf("expected string timestamps to pass through unchanged") + } +} + +func TestProxy_normalizeToolArguments_Availability_NullTimestamps(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + input := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":null,"end_time":null,"duration_minutes":30,"participants":[]}}}}` + result := proxy.normalizeToolArguments([]byte(input), nil) + + // null values are not numeric, toInt64 returns false, so no modification + if string(result) != input { + t.Errorf("expected null timestamps to pass through unchanged") + } +} + +func TestProxy_normalizeToolArguments_NoOp(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + tests := []struct { + name string + input string + }{ + { + name: "does not modify list_messages", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_messages","arguments":{"grant_id":"abc","get_all_query_parameters":{"limit":5}}}}`, + }, + { + name: "does not modify current_time", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"current_time","arguments":{"timezone":"UTC"}}}`, + }, + { + name: "does not modify epoch_to_datetime", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"epoch_to_datetime","arguments":{"batch":[{"epoch_time":1747065600,"timezone":"UTC"}]}}}`, + }, + { + name: "does not modify datetime_to_epoch", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"datetime_to_epoch","arguments":{"batch":[{"date":"2025-05-12","time":"14:00:00","timezone":"UTC"}]}}}`, + }, + { + name: "does not modify create_event", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_event","arguments":{"calendar_id":"primary","event_request":{"title":"Test"}}}}`, + }, + { + name: "does not modify send_message", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"send_message","arguments":{"confirmation_hash":"abc","message_request":{"to":[{"email":"t@t.com"}]}}}}`, + }, + { + name: "does not modify create_draft", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_draft","arguments":{"draft_request":{"to":[{"email":"t@t.com"}],"subject":"test"}}}}`, + }, + { + name: "does not modify list_contacts", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_contacts","arguments":{"query_parameters":{"limit":10}}}}`, + }, + { + name: "does not modify get_grant", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_grant","arguments":{"email":"user@example.com"}}}`, + }, + { + name: "does not modify get_search_syntax", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_search_syntax","arguments":{"provider":"google"}}}`, + }, + { + name: "does not modify non-tools/call methods", + input: `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`, + }, + { + name: "does not modify tools/list", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`, + }, + { + name: "does not modify notifications", + input: `{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}`, + }, + { + name: "handles nil arguments", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events"}}`, + }, + { + name: "handles empty arguments", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{}}}`, + }, + { + name: "handles list_events without get_all_query_parameters", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"grant_id":"abc"}}}`, + }, + { + name: "handles availability without availability_request", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"something":"else"}}}`, + }, + { + name: "handles availability with empty availability_request", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{}}}}`, + }, + { + name: "handles invalid JSON gracefully", + input: `not json at all`, + }, + { + name: "handles empty string", + input: ``, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := proxy.normalizeToolArguments([]byte(tt.input), nil) + if string(result) != tt.input { + t.Errorf("expected no modification\n input: %s\n output: %s", tt.input, string(result)) + } + }) + } +} + +func TestProxy_normalizeToolArguments_CombinedWithGrantInjection(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + proxy.SetDefaultGrant("my-grant-123") + + // Simulate the full pipeline: injectDefaultGrant + normalizeToolArguments + raw := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":1747065600,"end":1747152000,"limit":5}}}}`) + + var req rpcRequest + if err := json.Unmarshal(raw, &req); err != nil { + t.Fatal(err) + } + + // Apply both transformations like forward() does + result := proxy.injectDefaultGrant(raw, &req) + result = proxy.normalizeToolArguments(result, nil) // nil because injectDefaultGrant may have changed the bytes + + var parsed rpcRequest + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("failed to parse: %v", err) + } + + // Verify grant was injected + grantID, ok := parsed.Params.Arguments["grant_id"].(string) + if !ok || grantID != "my-grant-123" { + t.Errorf("grant_id = %v, want 'my-grant-123'", parsed.Params.Arguments["grant_id"]) + } + + // Verify start/end were coerced to strings + params := parsed.Params.Arguments["get_all_query_parameters"].(map[string]any) + if start, ok := params["start"].(string); !ok || start != "1747065600" { + t.Errorf("start = %v (%T), want string '1747065600'", params["start"], params["start"]) + } + if end, ok := params["end"].(string); !ok || end != "1747152000" { + t.Errorf("end = %v (%T), want string '1747152000'", params["end"], params["end"]) + } +} + +func TestRoundDown5Min(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input int64 + want int64 + }{ + {"zero", 0, 0}, + {"exact boundary 300", 300, 300}, + {"just below boundary", 299, 0}, + {"just above boundary", 301, 300}, + {"just below 600", 599, 300}, + {"exact 600", 600, 600}, + {"mid-block 150", 150, 0}, + {"mid-block 450", 450, 300}, + {"realistic timestamp +1s", 1747065601, 1747065600}, + {"realistic timestamp +299s", 1747065899, 1747065600}, + {"realistic timestamp exact", 1747065600, 1747065600}, + {"very large timestamp", 2524608001, 2524608000}, + {"one", 1, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := roundDown5Min(tt.input) + if got != tt.want { + t.Errorf("roundDown5Min(%d) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} + +func TestRoundUp5Min(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input int64 + want int64 + }{ + {"zero", 0, 0}, + {"exact boundary 300", 300, 300}, + {"just below boundary", 299, 300}, + {"just above boundary", 301, 600}, + {"just below 600", 599, 600}, + {"exact 600", 600, 600}, + {"mid-block 150", 150, 300}, + {"mid-block 450", 450, 600}, + {"realistic timestamp +1s", 1747065601, 1747065900}, + {"realistic timestamp +299s", 1747065899, 1747065900}, + {"realistic timestamp exact", 1747065900, 1747065900}, + {"very large timestamp", 2524608001, 2524608300}, + {"one", 1, 300}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := roundUp5Min(tt.input) + if got != tt.want { + t.Errorf("roundUp5Min(%d) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} + +func TestToInt64(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input any + want int64 + wantOK bool + }{ + {"float64", float64(1747065600), 1747065600, true}, + {"float64 zero", float64(0), 0, true}, + {"float64 negative", float64(-1), -1, true}, + {"float64 large", float64(2524608000), 2524608000, true}, + {"int", int(42), 42, true}, + {"int zero", int(0), 0, true}, + {"int negative", int(-100), -100, true}, + {"int64", int64(999), 999, true}, + {"int64 zero", int64(0), 0, true}, + {"json.Number valid", json.Number("1747065600"), 1747065600, true}, + {"json.Number zero", json.Number("0"), 0, true}, + {"json.Number float", json.Number("1.5"), 0, false}, + {"json.Number empty", json.Number(""), 0, false}, + {"json.Number invalid", json.Number("abc"), 0, false}, + {"string returns false", "not a number", 0, false}, + {"string numeric returns false", "12345", 0, false}, + {"nil returns false", nil, 0, false}, + {"bool true returns false", true, 0, false}, + {"bool false returns false", false, 0, false}, + {"slice returns false", []int{1, 2}, 0, false}, + {"map returns false", map[string]int{"a": 1}, 0, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := toInt64(tt.input) + if ok != tt.wantOK { + t.Errorf("toInt64(%v) ok = %v, want %v", tt.input, ok, tt.wantOK) + } + if got != tt.want { + t.Errorf("toInt64(%v) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} + +func TestNormalizeListEventsArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args map[string]any + wantMod bool + wantArgs map[string]any + }{ + { + name: "nil get_all_query_parameters", + args: map[string]any{"grant_id": "abc"}, + wantMod: false, + }, + { + name: "wrong type get_all_query_parameters", + args: map[string]any{"get_all_query_parameters": "not a map"}, + wantMod: false, + }, + { + name: "start is already string", + args: map[string]any{"get_all_query_parameters": map[string]any{ + "start": "12345", + }}, + wantMod: false, + }, + { + name: "start is float64", + args: map[string]any{"get_all_query_parameters": map[string]any{ + "start": float64(12345), + }}, + wantMod: true, + wantArgs: map[string]any{"get_all_query_parameters": map[string]any{ + "start": "12345", + }}, + }, + { + name: "empty params map", + args: map[string]any{"get_all_query_parameters": map[string]any{}}, + wantMod: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeListEventsArgs(tt.args) + if got != tt.wantMod { + t.Errorf("normalizeListEventsArgs() modified = %v, want %v", got, tt.wantMod) + } + }) + } +} + +func TestNormalizeAvailabilityArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args map[string]any + wantMod bool + }{ + { + name: "nil availability_request", + args: map[string]any{"other": "stuff"}, + wantMod: false, + }, + { + name: "wrong type availability_request", + args: map[string]any{"availability_request": "not a map"}, + wantMod: false, + }, + { + name: "already aligned", + args: map[string]any{"availability_request": map[string]any{ + "start_time": float64(300), + "end_time": float64(600), + }}, + wantMod: false, + }, + { + name: "needs rounding", + args: map[string]any{"availability_request": map[string]any{ + "start_time": float64(301), + "end_time": float64(599), + }}, + wantMod: true, + }, + { + name: "empty availability_request", + args: map[string]any{"availability_request": map[string]any{}}, + wantMod: false, + }, + { + name: "string timestamps ignored", + args: map[string]any{"availability_request": map[string]any{ + "start_time": "not_numeric", + "end_time": "also_not", + }}, + wantMod: false, + }, + { + name: "boolean timestamps ignored", + args: map[string]any{"availability_request": map[string]any{ + "start_time": true, + "end_time": false, + }}, + wantMod: false, + }, + { + name: "nil timestamps ignored", + args: map[string]any{"availability_request": map[string]any{ + "start_time": nil, + "end_time": nil, + }}, + wantMod: false, + }, + { + name: "array timestamps ignored", + args: map[string]any{"availability_request": map[string]any{ + "start_time": []any{1, 2, 3}, + "end_time": []any{4, 5, 6}, + }}, + wantMod: false, + }, + { + name: "object timestamps ignored", + args: map[string]any{"availability_request": map[string]any{ + "start_time": map[string]any{"nested": true}, + "end_time": map[string]any{"nested": true}, + }}, + wantMod: false, + }, + { + name: "only start_time needs rounding", + args: map[string]any{"availability_request": map[string]any{ + "start_time": float64(301), + "end_time": float64(600), + }}, + wantMod: true, + }, + { + name: "only end_time needs rounding", + args: map[string]any{"availability_request": map[string]any{ + "start_time": float64(300), + "end_time": float64(599), + }}, + wantMod: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeAvailabilityArgs(tt.args) + if got != tt.wantMod { + t.Errorf("normalizeAvailabilityArgs() modified = %v, want %v", got, tt.wantMod) + } + }) + } +} + +func TestNormalizeListEventsArgs_ExoticTypes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args map[string]any + wantMod bool + }{ + { + name: "boolean start ignored", + args: map[string]any{"get_all_query_parameters": map[string]any{ + "start": true, + }}, + wantMod: false, + }, + { + name: "null start ignored", + args: map[string]any{"get_all_query_parameters": map[string]any{ + "start": nil, + }}, + wantMod: false, + }, + { + name: "array start ignored", + args: map[string]any{"get_all_query_parameters": map[string]any{ + "start": []any{1, 2}, + }}, + wantMod: false, + }, + { + name: "object start ignored", + args: map[string]any{"get_all_query_parameters": map[string]any{ + "start": map[string]any{"nested": "value"}, + }}, + wantMod: false, + }, + { + name: "boolean end ignored", + args: map[string]any{"get_all_query_parameters": map[string]any{ + "end": false, + }}, + wantMod: false, + }, + { + name: "null end ignored", + args: map[string]any{"get_all_query_parameters": map[string]any{ + "end": nil, + }}, + wantMod: false, + }, + { + name: "only start is numeric (end is string)", + args: map[string]any{"get_all_query_parameters": map[string]any{ + "start": float64(100), + "end": "200", + }}, + wantMod: true, + }, + { + name: "only end is numeric (start is string)", + args: map[string]any{"get_all_query_parameters": map[string]any{ + "start": "100", + "end": float64(200), + }}, + wantMod: true, + }, + { + name: "negative start coerced", + args: map[string]any{"get_all_query_parameters": map[string]any{ + "start": float64(-1), + }}, + wantMod: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeListEventsArgs(tt.args) + if got != tt.wantMod { + t.Errorf("normalizeListEventsArgs() modified = %v, want %v", got, tt.wantMod) + } + }) + } +} + +func TestProxy_normalizeToolArguments_ListEvents_ExoticValues(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + tests := []struct { + name string + input string + expectNoMod bool + }{ + { + name: "null start and end pass through unchanged", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":null,"end":null}}}}`, + expectNoMod: true, + }, + { + name: "boolean start and end pass through unchanged", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":true,"end":false}}}}`, + expectNoMod: true, + }, + { + name: "array start and end pass through unchanged", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":[1,2],"end":[3,4]}}}}`, + expectNoMod: true, + }, + { + name: "object start and end pass through unchanged", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":{"v":1},"end":{"v":2}}}}}`, + expectNoMod: true, + }, + { + name: "get_all_query_parameters is a string", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":"not_a_map"}}}`, + expectNoMod: true, + }, + { + name: "get_all_query_parameters is null", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":null}}}`, + expectNoMod: true, + }, + { + name: "get_all_query_parameters is an array", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":[1,2,3]}}}`, + expectNoMod: true, + }, + { + name: "get_all_query_parameters is a number", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":42}}}`, + expectNoMod: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := proxy.normalizeToolArguments([]byte(tt.input), nil) + if tt.expectNoMod && string(result) != tt.input { + t.Errorf("expected no modification\n input: %s\n output: %s", tt.input, string(result)) + } + }) + } +} + +func TestProxy_normalizeToolArguments_Availability_ExoticValues(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + tests := []struct { + name string + input string + expectNoMod bool + }{ + { + name: "boolean start_time and end_time pass through", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":true,"end_time":false,"duration_minutes":30,"participants":[]}}}}`, + expectNoMod: true, + }, + { + name: "null start_time and end_time pass through", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":null,"end_time":null,"duration_minutes":30,"participants":[]}}}}`, + expectNoMod: true, + }, + { + name: "array start_time and end_time pass through", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":[1],"end_time":[2],"duration_minutes":30,"participants":[]}}}}`, + expectNoMod: true, + }, + { + name: "object start_time and end_time pass through", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":{"v":1},"end_time":{"v":2},"duration_minutes":30,"participants":[]}}}}`, + expectNoMod: true, + }, + { + name: "availability_request is a string", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":"not_a_map"}}}`, + expectNoMod: true, + }, + { + name: "availability_request is null", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":null}}}`, + expectNoMod: true, + }, + { + name: "availability_request is an array", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":[1,2]}}}`, + expectNoMod: true, + }, + { + name: "availability_request is a number", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":99}}}`, + expectNoMod: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := proxy.normalizeToolArguments([]byte(tt.input), nil) + if tt.expectNoMod && string(result) != tt.input { + t.Errorf("expected no modification\n input: %s\n output: %s", tt.input, string(result)) + } + }) + } +} + +func TestProxy_normalizeToolArguments_CrossToolNoOp(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + // These tools also use get_all_query_parameters but should NOT have start/end coerced + // (normalization only targets list_events) + tests := []struct { + name string + input string + }{ + { + name: "list_messages with get_all_query_parameters is not modified", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_messages","arguments":{"get_all_query_parameters":{"limit":5,"received_after":1747065600}}}}`, + }, + { + name: "list_threads with get_all_query_parameters is not modified", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_threads","arguments":{"get_all_query_parameters":{"limit":5}}}}`, + }, + { + name: "list_contacts with query_parameters is not modified", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_contacts","arguments":{"query_parameters":{"limit":5}}}}`, + }, + { + name: "list_folders is not modified", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_folders","arguments":{"query_parameters":{"limit":50}}}}`, + }, + { + name: "get_event is not modified", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_event","arguments":{"calendar_id":"primary","event_id":"evt-123"}}}`, + }, + { + name: "create_event is not modified", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_event","arguments":{"calendar_id":"primary","event_request":{"title":"Test","when":{"start_time":1747065601,"end_time":1747065899}}}}}`, + }, + { + name: "update_event is not modified", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"update_event","arguments":{"calendar_id":"primary","event_id":"evt-1","event_request":{"title":"Updated"}}}}`, + }, + { + name: "delete_event is not modified", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"delete_event","arguments":{"calendar_id":"primary","event_id":"evt-1"}}}`, + }, + { + name: "list_calendars is not modified", + input: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_calendars","arguments":{}}}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := proxy.normalizeToolArguments([]byte(tt.input), nil) + if string(result) != tt.input { + t.Errorf("expected no modification for %s\n input: %s\n output: %s", tt.name, tt.input, string(result)) + } + }) + } +} + +func TestProxy_normalizeToolArguments_EmptyToolName(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + input := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"","arguments":{"get_all_query_parameters":{"start":12345}}}}` + result := proxy.normalizeToolArguments([]byte(input), nil) + if string(result) != input { + t.Error("expected empty tool name to cause no modification") + } +} + +func TestProxy_normalizeToolArguments_MissingToolName(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + input := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"arguments":{"get_all_query_parameters":{"start":12345}}}}` + result := proxy.normalizeToolArguments([]byte(input), nil) + if string(result) != input { + t.Error("expected missing tool name to cause no modification") + } +} + +func TestProxy_normalizeToolArguments_ConcurrentSafety(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + listEventsInput := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"start":1747065600,"end":1747152000}}}}` + availInput := `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":1747065601,"end_time":1747065899,"duration_minutes":30,"participants":[]}}}}` + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(2) + go func() { + defer wg.Done() + result := proxy.normalizeToolArguments([]byte(listEventsInput), nil) + var parsed rpcRequest + if err := json.Unmarshal(result, &parsed); err != nil { + t.Errorf("concurrent list_events parse failed: %v", err) + return + } + params, ok := parsed.Params.Arguments["get_all_query_parameters"].(map[string]any) + if !ok { + t.Error("concurrent list_events: missing get_all_query_parameters") + return + } + if _, ok := params["start"].(string); !ok { + t.Errorf("concurrent list_events: start not coerced, got %T", params["start"]) + } + }() + go func() { + defer wg.Done() + result := proxy.normalizeToolArguments([]byte(availInput), nil) + var parsed rpcRequest + if err := json.Unmarshal(result, &parsed); err != nil { + t.Errorf("concurrent availability parse failed: %v", err) + return + } + req, ok := parsed.Params.Arguments["availability_request"].(map[string]any) + if !ok { + t.Error("concurrent availability: missing availability_request") + return + } + st, ok := req["start_time"].(float64) + if !ok { + t.Errorf("concurrent availability: start_time not float64, got %T", req["start_time"]) + return + } + if st != 1747065600 { + t.Errorf("concurrent availability: start_time = %v, want 1747065600", st) + } + }() + } + wg.Wait() +} + +func TestProxy_normalizeToolArguments_Idempotent(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + // Applying normalization twice should produce the same result + input := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"calendar_id":"primary","start":1747065600,"end":1747152000}}}}` + + first := proxy.normalizeToolArguments([]byte(input), nil) + second := proxy.normalizeToolArguments(first, nil) + + if string(first) != string(second) { + t.Errorf("normalization is not idempotent\n first: %s\n second: %s", first, second) + } + + // Same for availability + availInput := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"availability","arguments":{"availability_request":{"start_time":1747065601,"end_time":1747065899,"duration_minutes":30,"participants":[]}}}}` + + first = proxy.normalizeToolArguments([]byte(availInput), nil) + second = proxy.normalizeToolArguments(first, nil) + + if string(first) != string(second) { + t.Errorf("availability normalization is not idempotent\n first: %s\n second: %s", first, second) + } +} + +func TestProxy_normalizeToolArguments_PreservesJSONRPCFields(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + input := `{"jsonrpc":"2.0","id":42,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"start":1747065600,"end":1747152000}}}}` + result := proxy.normalizeToolArguments([]byte(input), nil) + + var parsed map[string]any + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("failed to parse: %v", err) + } + + if parsed["jsonrpc"] != "2.0" { + t.Errorf("jsonrpc = %v, want '2.0'", parsed["jsonrpc"]) + } + if parsed["id"] != float64(42) { + t.Errorf("id = %v, want 42", parsed["id"]) + } + if parsed["method"] != "tools/call" { + t.Errorf("method = %v, want 'tools/call'", parsed["method"]) + } +} + +func TestProxy_normalizeToolArguments_PreservesStringID(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + input := `{"jsonrpc":"2.0","id":"request-abc","method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"start":1747065600}}}}` + result := proxy.normalizeToolArguments([]byte(input), nil) + + var parsed map[string]any + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("failed to parse: %v", err) + } + + if parsed["id"] != "request-abc" { + t.Errorf("id = %v, want 'request-abc'", parsed["id"]) + } +} + +func TestProxy_normalizeToolArguments_PreservesNullID(t *testing.T) { + t.Parallel() + + proxy := NewProxy("test-api-key", "us") + + input := `{"jsonrpc":"2.0","id":null,"method":"tools/call","params":{"name":"list_events","arguments":{"get_all_query_parameters":{"start":1747065600}}}}` + result := proxy.normalizeToolArguments([]byte(input), nil) + + var parsed map[string]any + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("failed to parse: %v", err) + } + + if parsed["id"] != nil { + t.Errorf("id = %v, want nil", parsed["id"]) + } +}