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
2 changes: 1 addition & 1 deletion ci/tests/metrics/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
version: "3"

vars:
PROFILES: default custom disabled cardinality response-headers
PROFILES: default custom disabled cardinality response-headers mcp

tasks:
default:
Expand Down
72 changes: 72 additions & 0 deletions ci/tests/metrics/apps/test-mcpapi-mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{
"openapi": "3.0.3",
"info": {
"title": "TestMCPAPI",
"version": "1.0.0"
},
"servers": [
{
"url": "http://localhost:8080/mcp"
}
],
"paths": {
"/tools/call": {
"post": {
"operationId": "toolsCall",
"responses": {
"200": {
"description": "Success"
}
}
}
}
},
"x-tyk-api-gateway": {
"info": {
"id": "mcp-1",
"orgId": "default",
"name": "TestMCPAPI",
"state": {
"active": true
}
},
"upstream": {
"url": "http://mcp-server:3001/mcp"
},
"server": {
"listenPath": {
"value": "/mcp/",
"strip": true
},
"authentication": {
"enabled": false
}
},
"middleware": {
"mcpTools": {
"echo": {
"allow": {
"enabled": true
}
},
"add": {
"allow": {
"enabled": true
}
},
"longRunningOperation": {
"allow": {
"enabled": true
}
}
},
"operations": {
"toolsCall": {
"allow": {
"enabled": false
}
}
}
}
}
}
37 changes: 37 additions & 0 deletions ci/tests/metrics/apps/test-mcpapi.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "TestMCPAPI",
"api_id": "mcp-1",
"org_id": "default",
"is_oas": true,
"definition": {
"location": "",
"key": ""
},
"use_keyless": true,
"auth": {
"auth_header_name": ""
},
"json_rpc_version": "2.0",
"application_protocol": "mcp",
"version_data": {
"not_versioned": true,
"versions": {
"Default": {
"name": "Default",
"expires": "3000-01-02 15:04",
"use_extended_paths": true,
"extended_paths": {
"ignored": [],
"white_list": [],
"black_list": []
}
}
}
},
"proxy": {
"listen_path": "/mcp/",
"target_url": "http://mcp-server:3001/mcp",
"strip_listen_path": true
},
"do_not_track": false
}
7 changes: 7 additions & 0 deletions ci/tests/metrics/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ services:
volumes:
- ./configs/otelcollector/collector.config.yml:/otel-local-config.yml

mcp-server:
image: tzolov/mcp-everything-server:v3
platform: linux/amd64
command: ["node", "dist/index.js", "streamableHttp"]
expose:
- "3001"

prometheus:
image: prom/prometheus:v2.51.0
platform: linux/amd64
Expand Down
243 changes: 243 additions & 0 deletions ci/tests/metrics/metrics_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package main

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
Expand Down Expand Up @@ -814,6 +816,144 @@
assertMetricExists(t, `tyk_requests_by_backend_version_total{tyk_api_id="8",backend_version="unknown"}`)
}

// ---------- MCP profile tests ----------

func TestMCPProfile_MetricEmission(t *testing.T) {
if gwProfile() != "mcp" {

Check warning on line 822 in ci/tests/metrics/metrics_test.go

View check run for this annotation

probelabs / Visor: quality

logic Issue

The end-to-end tests for MCP metrics do not cover scenarios where a JSON-RPC error occurs. While unit tests validate the `mcp_error_code` dimension, there is no E2E test that triggers an error (e.g., by calling a non-existent tool) and verifies that the corresponding metric is emitted with the correct `mcp.error.code` label in Prometheus.
Raw output
Add a new test case to the MCP profile that sends an invalid JSON-RPC request, such as a `tools/call` for a tool not defined in the API spec. This test should then assert that a metric is produced with the expected `mcp_error_code` dimension (e.g., `mcp_error_code="-32602"` for Invalid Params).
t.Skip("only runs under mcp profile")
}
waitForGateway(t)

// Send a tools/call JSON-RPC request through the MCP API.
// The MCP everything server at mcp-server:3001 should handle this.
sendJSONRPC(t, gatewayURL+"/mcp/", "tools/call", map[string]interface{}{
"name": "echo",
"arguments": map[string]string{"message": "hello"},
})

// Assert MCP counter exists with expected dimensions.
assertMetricExists(t, `tyk_mcp_requests_total{mcp_method_name="tools/call",mcp_primitive_type="tool"}`)
}

func TestMCPProfile_PrimitiveName(t *testing.T) {
if gwProfile() != "mcp" {
t.Skip("only runs under mcp profile")
}
waitForGateway(t)

sendJSONRPC(t, gatewayURL+"/mcp/", "tools/call", map[string]interface{}{
"name": "echo",
"arguments": map[string]string{"message": "test"},
})

// Assert primitive name dimension is populated.
assertMetricExists(t, `tyk_mcp_requests_total{mcp_primitive_name="echo",mcp_primitive_type="tool"}`)
}

func TestMCPProfile_NonMCPIsolation(t *testing.T) {
if gwProfile() != "mcp" {
t.Skip("only runs under mcp profile")
}
waitForGateway(t)

// Send regular REST traffic to the non-MCP API.
sendTraffic(t, "GET", gatewayURL+"/test/ip", 10)

// Send MCP traffic.
sendJSONRPC(t, gatewayURL+"/mcp/", "tools/call", map[string]interface{}{
"name": "echo",
"arguments": map[string]string{"message": "test"},
})

// REST API metrics should have empty MCP labels.
// MCP counter should exist with MCP labels populated.
assertMetricExists(t, `tyk_mcp_requests_total{mcp_method_name="tools/call"}`)
}

func TestMCPProfile_HistogramDuration(t *testing.T) {
if gwProfile() != "mcp" {
t.Skip("only runs under mcp profile")
}
waitForGateway(t)

for i := 0; i < 5; i++ {
sendJSONRPC(t, gatewayURL+"/mcp/", "tools/call", map[string]interface{}{
"name": "echo",
"arguments": map[string]string{"message": "test"},
})
}

// Assert histogram exists.
assertMetricExists(t, `tyk_mcp_primitive_duration_seconds_count{mcp_primitive_type="tool",mcp_primitive_name="echo"}`)
}

func TestMCPProfile_HTTPServerDurationWithMCPMethod(t *testing.T) {
if gwProfile() != "mcp" {
t.Skip("only runs under mcp profile")
}
waitForGateway(t)

sendJSONRPC(t, gatewayURL+"/mcp/", "tools/call", map[string]interface{}{
"name": "echo",
"arguments": map[string]string{"message": "test"},
})

// http.server.request.duration should also have the mcp.method.name label.
assertMetricExists(t, `http_server_request_duration_seconds_count{mcp_method_name="tools/call"}`)
}

func TestMCPProfile_SessionIdFromHeader(t *testing.T) {
if gwProfile() != "mcp" {
t.Skip("only runs under mcp profile")
}
waitForGateway(t)

sendJSONRPCWithHeaders(t, gatewayURL+"/mcp/", "tools/call", map[string]interface{}{
"name": "echo",
"arguments": map[string]string{"message": "test"},
}, map[string]string{
"Mcp-Session-Id": "test-session-123",
})

// http.server.request.duration should have mcp.session.id from header.
assertMetricExists(t, `http_server_request_duration_seconds_count{mcp_session_id="test-session-123"}`)
}

func TestMCPProfile_InitializeMethod(t *testing.T) {
if gwProfile() != "mcp" {
t.Skip("only runs under mcp profile")
}
waitForGateway(t)

// Initialize is a non-primitive method (no tool/resource/prompt).
sendJSONRPC(t, gatewayURL+"/mcp/", "initialize", map[string]interface{}{
"protocolVersion": "2025-06-18",
"capabilities": map[string]interface{}{},
"clientInfo": map[string]interface{}{"name": "test-client", "version": "1.0"},
})

// Should have mcp_method_name="initialize" but empty primitive type/name.
assertMetricExists(t, `tyk_mcp_requests_total{mcp_method_name="initialize"}`)
}

func TestMCPProfile_CounterHasAllExpectedLabels(t *testing.T) {
if gwProfile() != "mcp" {
t.Skip("only runs under mcp profile")
}
waitForGateway(t)

// Use proper MCP session so the upstream returns 200.
sendMCPToolCall(t, gatewayURL+"/mcp/", "echo", map[string]string{"message": "test"})

query := `tyk_mcp_requests_total{api_id="mcp-1",mcp_method_name="tools/call",mcp_primitive_name="echo",response_code="200"}`
assertLabelEquals(t, query, "mcp_method_name", "tools/call")
assertLabelEquals(t, query, "mcp_primitive_type", "tool")
assertLabelEquals(t, query, "mcp_primitive_name", "echo")
assertLabelEquals(t, query, "response_code", "200")
assertLabelEquals(t, query, "api_id", "mcp-1")
// mcp_error_code is omitted by Prometheus when empty (no error).
}

// ---------- helpers ----------

func waitForGateway(t *testing.T) {
Expand Down Expand Up @@ -1097,3 +1237,106 @@
}
t.Fatalf("%s label %s=%q does not contain %q", query, labelKey, actualVal, expected)
}

// sendJSONRPC sends a JSON-RPC 2.0 request to the given URL.
func sendJSONRPC(t *testing.T, url, method string, params interface{}) {
t.Helper()
sendJSONRPCWithHeaders(t, url, method, params, nil)
}

// sendJSONRPCWithHeaders sends a JSON-RPC 2.0 request with custom headers.
func sendJSONRPCWithHeaders(t *testing.T, url, method string, params interface{}, headers map[string]string) {
t.Helper()
doJSONRPC(t, url, method, params, headers)
}

// doJSONRPC sends a JSON-RPC 2.0 request and returns the HTTP response.
func doJSONRPC(t *testing.T, url, method string, params interface{}, headers map[string]string) *http.Response {
t.Helper()
body := map[string]interface{}{
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": 1,
}
jsonBody, err := json.Marshal(body)
if err != nil {
t.Fatalf("failed to marshal JSON-RPC body: %v", err)
}

req, err := http.NewRequest("POST", url, bytes.NewReader(jsonBody))
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
for k, v := range headers {
req.Header.Set(k, v)
}

client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("JSON-RPC request failed: %v", err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return resp
}

// initMCPSession performs the MCP initialize handshake and returns the session ID.
// The Streamable HTTP transport requires: initialize → (get Mcp-Session-Id) → initialized notification.
func initMCPSession(t *testing.T, mcpURL string) string {
t.Helper()

// Step 1: Send initialize request.
resp := doJSONRPC(t, mcpURL, "initialize", map[string]interface{}{
"protocolVersion": "2025-06-18",
"capabilities": map[string]interface{}{},
"clientInfo": map[string]interface{}{"name": "test-client", "version": "1.0"},
}, nil)

if resp.StatusCode != http.StatusOK {
t.Fatalf("initialize returned HTTP %d (expected 200); check that operation-level allow is disabled", resp.StatusCode)
}

sessionID := resp.Header.Get("Mcp-Session-Id")
if sessionID == "" {
t.Logf("initialize response headers: %v", resp.Header)
t.Fatal("MCP server did not return Mcp-Session-Id header on initialize")
}

// Step 2: Send initialized notification (no id field = notification).
notifBody, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0",
"method": "notifications/initialized",
})
req, err := http.NewRequest("POST", mcpURL, bytes.NewReader(notifBody))
if err != nil {
t.Fatalf("failed to create initialized notification request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Mcp-Session-Id", sessionID)

client := &http.Client{Timeout: 10 * time.Second}
notifResp, err := client.Do(req)
if err != nil {
t.Fatalf("initialized notification failed: %v", err)
}
notifResp.Body.Close()

return sessionID
}

// sendMCPToolCall initializes an MCP session and sends a tools/call request.
func sendMCPToolCall(t *testing.T, mcpURL, toolName string, args map[string]string) {
t.Helper()
sessionID := initMCPSession(t, mcpURL)
sendJSONRPCWithHeaders(t, mcpURL, "tools/call", map[string]interface{}{
"name": toolName,
"arguments": args,
}, map[string]string{
"Mcp-Session-Id": sessionID,
})
}
Loading
Loading