From 7d96a8443ab49ef1b8d67df55d4fd2800e76cad0 Mon Sep 17 00:00:00 2001 From: zer0stars <74260741+zer0stars@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:53:32 -0400 Subject: [PATCH] test(e2e): cover MCP endpoint and overridden signal tools Adds the first e2e coverage for the /mcp path, automating the manual smoke test run against prod v0.4.12: - tools/list exposes the overridden shortcut tools with required ToolOnly args (signalRequests / signalNames) - telemetry_get_latest_signals and telemetry_get_signals_time_series return seeded ClickHouse data through the full chain: JSON-RPC -> selection template render -> gqlgen executor with shared extensions -> privilege directives -> ClickHouse - a signalRequests entry missing 'agg' fails template rendering with a clear error before the executor runs - missing or privilege-less JWTs surface as GraphQL unauthorized errors --- e2e/mcp_test.go | 286 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 e2e/mcp_test.go diff --git a/e2e/mcp_test.go b/e2e/mcp_test.go new file mode 100644 index 0000000..f17f795 --- /dev/null +++ b/e2e/mcp_test.go @@ -0,0 +1,286 @@ +package e2e_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/DIMO-Network/cloudevent" + "github.com/DIMO-Network/model-garage/pkg/vss" + "github.com/DIMO-Network/telemetry-api/internal/app" + "github.com/DIMO-Network/telemetry-api/internal/config" + "github.com/DIMO-Network/token-exchange-api/pkg/tokenclaims" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const mcpTestTokenID = 77001 + +// newMCPServer builds the application and serves its MCP handler over HTTP. +func newMCPServer(t *testing.T, settings config.Settings) *httptest.Server { + t.Helper() + + zerolog.SetGlobalLevel(zerolog.PanicLevel) + testLogger := zerolog.New(os.Stdout).With().Timestamp().Logger() + zerolog.DefaultContextLogger = &testLogger + + application, err := app.New(settings) + require.NoError(t, err) + t.Cleanup(application.Cleanup) + + server := httptest.NewServer(application.MCPHandler) + t.Cleanup(server.Close) + return server +} + +// mcpRequest posts a single JSON-RPC request to the stateless MCP endpoint and +// returns the decoded "result" object. Responses may arrive as SSE +// ("data: {...}") or plain JSON depending on transport negotiation. +func mcpRequest(t *testing.T, serverURL, token, method string, params any) map[string]any { + t.Helper() + + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodPost, serverURL, bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + raw := new(bytes.Buffer) + _, err = raw.ReadFrom(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "MCP response body: %s", raw.String()) + + payload := raw.String() + if idx := strings.Index(payload, "data: "); idx >= 0 { + payload = payload[idx+len("data: "):] + if end := strings.IndexByte(payload, '\n'); end >= 0 { + payload = payload[:end] + } + } + + var rpc struct { + Result map[string]any `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal([]byte(payload), &rpc), "raw payload: %s", payload) + require.Nil(t, rpc.Error, "JSON-RPC error from MCP server") + return rpc.Result +} + +// callTool invokes an MCP tool and returns (text content, isError). +func callTool(t *testing.T, serverURL, token, name string, args map[string]any) (string, bool) { + t.Helper() + result := mcpRequest(t, serverURL, token, "tools/call", map[string]any{ + "name": name, + "arguments": args, + }) + + content, ok := result["content"].([]any) + require.True(t, ok && len(content) > 0, "tool result missing content: %v", result) + first, ok := content[0].(map[string]any) + require.True(t, ok) + text, _ := first["text"].(string) + isError, _ := result["isError"].(bool) + return text, isError +} + +func TestMCPToolsList(t *testing.T) { + services := GetTestServices(t) + server := newMCPServer(t, services.Settings) + + result := mcpRequest(t, server.URL, "", "tools/list", map[string]any{}) + toolsRaw, ok := result["tools"].([]any) + require.True(t, ok, "tools/list result missing tools: %v", result) + + tools := make(map[string]map[string]any, len(toolsRaw)) + for _, raw := range toolsRaw { + tool, ok := raw.(map[string]any) + require.True(t, ok) + tools[tool["name"].(string)] = tool + } + + // Builtin tools. + require.Contains(t, tools, "telemetry_get_schema") + require.Contains(t, tools, "telemetry_query") + + // The two overridden shortcut tools must require their ToolOnly signal args. + timeSeries, ok := tools["telemetry_get_signals_time_series"] + require.True(t, ok, "overridden time-series tool missing") + assert.Contains(t, requiredArgs(t, timeSeries), "signalRequests") + + latest, ok := tools["telemetry_get_latest_signals"] + require.True(t, ok, "overridden latest-signals tool missing") + assert.Contains(t, requiredArgs(t, latest), "signalNames") +} + +func requiredArgs(t *testing.T, tool map[string]any) []string { + t.Helper() + schema, ok := tool["inputSchema"].(map[string]any) + require.True(t, ok, "tool %v missing inputSchema", tool["name"]) + rawRequired, _ := schema["required"].([]any) + required := make([]string, 0, len(rawRequired)) + for _, r := range rawRequired { + required = append(required, r.(string)) + } + return required +} + +func TestMCPSignalTools(t *testing.T) { + services := GetTestServices(t) + + baseTime := time.Date(2024, 11, 20, 10, 0, 0, 0, time.UTC) + subject := "did:erc721:137:0xbA5738a18d83D41847dfFbDC6101d37C69c9B0cF:77001" + source := "0xcd445F4c6bDAD32b68a2939b912150Fe3C88803E" + signals := []vss.Signal{ + { + CloudEventHeader: cloudevent.CloudEventHeader{Source: source, Subject: subject}, + Data: vss.SignalData{Timestamp: baseTime.Add(15 * time.Minute), Name: vss.FieldSpeed, ValueNumber: 30}, + }, + { + CloudEventHeader: cloudevent.CloudEventHeader{Source: source, Subject: subject}, + Data: vss.SignalData{Timestamp: baseTime.Add(45 * time.Minute), Name: vss.FieldSpeed, ValueNumber: 80}, + }, + { + CloudEventHeader: cloudevent.CloudEventHeader{Source: source, Subject: subject}, + Data: vss.SignalData{Timestamp: baseTime.Add(30 * time.Minute), Name: vss.FieldPowertrainTractionBatteryStateOfChargeCurrent, ValueNumber: 50}, + }, + { + CloudEventHeader: cloudevent.CloudEventHeader{Source: source, Subject: subject}, + Data: vss.SignalData{Timestamp: baseTime.Add(90 * time.Minute), Name: vss.FieldPowertrainTractionBatteryStateOfChargeCurrent, ValueNumber: 42.5}, + }, + } + insertSignal(t, services.CH, signals) + + server := newMCPServer(t, services.Settings) + token := services.Auth.CreateVehicleToken(t, mcpTestTokenID, []string{tokenclaims.PermissionGetNonLocationHistory}) + + t.Run("latest signals returns data", func(t *testing.T) { + text, isError := callTool(t, server.URL, token, "telemetry_get_latest_signals", map[string]any{ + "tokenId": mcpTestTokenID, + "signalNames": []string{"speed", "powertrainTractionBatteryStateOfChargeCurrent"}, + }) + require.False(t, isError, "tool error: %s", text) + + var resp struct { + Data struct { + SignalsLatest struct { + LastSeen string `json:"lastSeen"` + Speed struct { + Timestamp string `json:"timestamp"` + Value float64 `json:"value"` + } `json:"speed"` + SoC struct { + Value float64 `json:"value"` + } `json:"powertrainTractionBatteryStateOfChargeCurrent"` + } `json:"signalsLatest"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + require.NoError(t, json.Unmarshal([]byte(text), &resp)) + require.Empty(t, resp.Errors, "GraphQL errors: %s", text) + + assert.Equal(t, 80.0, resp.Data.SignalsLatest.Speed.Value) + assert.Equal(t, baseTime.Add(45*time.Minute).Format(time.RFC3339), resp.Data.SignalsLatest.Speed.Timestamp) + assert.Equal(t, 42.5, resp.Data.SignalsLatest.SoC.Value) + assert.Equal(t, baseTime.Add(90*time.Minute).Format(time.RFC3339), resp.Data.SignalsLatest.LastSeen) + }) + + t.Run("time series aggregates named signals", func(t *testing.T) { + text, isError := callTool(t, server.URL, token, "telemetry_get_signals_time_series", map[string]any{ + "tokenId": mcpTestTokenID, + "interval": "1h", + "from": baseTime.Format(time.RFC3339), + "to": baseTime.Add(2 * time.Hour).Format(time.RFC3339), + "signalRequests": []map[string]any{ + {"name": "speed", "agg": "MAX"}, + {"name": "powertrainTractionBatteryStateOfChargeCurrent", "agg": "LAST"}, + }, + }) + require.False(t, isError, "tool error: %s", text) + + var resp struct { + Data struct { + Signals []struct { + Timestamp string `json:"timestamp"` + Speed *float64 `json:"speed"` + SoC *float64 `json:"powertrainTractionBatteryStateOfChargeCurrent"` + } `json:"signals"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + require.NoError(t, json.Unmarshal([]byte(text), &resp)) + require.Empty(t, resp.Errors, "GraphQL errors: %s", text) + require.Len(t, resp.Data.Signals, 2) + + first := resp.Data.Signals[0] + require.NotNil(t, first.Speed) + assert.Equal(t, 80.0, *first.Speed, "MAX of speed in first bucket") + require.NotNil(t, first.SoC) + assert.Equal(t, 50.0, *first.SoC) + + second := resp.Data.Signals[1] + assert.Nil(t, second.Speed, "no speed data in second bucket") + require.NotNil(t, second.SoC) + assert.Equal(t, 42.5, *second.SoC, "LAST of SoC in second bucket") + }) + + t.Run("missing agg key fails template render before execution", func(t *testing.T) { + text, isError := callTool(t, server.URL, token, "telemetry_get_signals_time_series", map[string]any{ + "tokenId": mcpTestTokenID, + "interval": "1h", + "from": baseTime.Format(time.RFC3339), + "to": baseTime.Add(2 * time.Hour).Format(time.RFC3339), + "signalRequests": []map[string]any{ + {"name": "speed"}, + }, + }) + require.True(t, isError) + assert.Contains(t, text, "failed to render selection template") + assert.NotContains(t, text, "") + }) + + t.Run("unauthenticated call is rejected at the resolver", func(t *testing.T) { + text, isError := callTool(t, server.URL, "", "telemetry_get_latest_signals", map[string]any{ + "tokenId": mcpTestTokenID, + "signalNames": []string{"speed"}, + }) + require.False(t, isError, "auth failures surface as GraphQL errors, not tool errors: %s", text) + assert.Contains(t, text, "unauthorized") + }) + + t.Run("token without privilege cannot read signals", func(t *testing.T) { + noPrivToken := services.Auth.CreateVehicleToken(t, mcpTestTokenID, []string{}) + text, _ := callTool(t, server.URL, noPrivToken, "telemetry_get_latest_signals", map[string]any{ + "tokenId": mcpTestTokenID, + "signalNames": []string{"speed"}, + }) + assert.Contains(t, text, "unauthorized") + }) +}