Skip to content

Commit 5fe0cdb

Browse files
committed
feat: expose metricsfor MCP calls
the `mcp_calls_total` and `mcp_call_duration_seconds` metrics are collected via the SDK's `mcp.Middleware` and exposed on the `/metrics` endpoint of the server Signed-off-by: Xavier Coulon <xcoulon@redhat.com>
1 parent 0bb2ef1 commit 5fe0cdb

8 files changed

Lines changed: 114 additions & 16 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ argocd-mcp-server --transport=http --argocd-url=<url> --argocd-token=<token> --d
105105
Or start the Argo CD MCP server as a container after running `task build-image`:
106106

107107
```bash
108-
podman run -d --name argocd-mcp-server --transport http -e ARGOCD_MCP_URL=<url> -e ARGOCD_MCP_TOKEN=<token> -e ARGOCD_MCP_DEBUG=<true|false> -p 8080:8080 argocd-mcp-server:latest
108+
podman run -d --name argocd-mcp-server --transport http -e ARGOCD_MCP_SERVER_LISTEN_HOST=0.0.0.0 -e ARGOCD_MCP_URL=<url> -e ARGOCD_MCP_TOKEN=<token> -e ARGOCD_MCP_DEBUG=<true|false> -p 8080:8080 argocd-mcp-server:latest
109109
```
110110

111111
Edit your `~/.cursor/mcp.json` file with the following contents:

cmd/start_server.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/codeready-toolchain/argocd-mcp-server/internal/argocd"
1313
"github.com/codeready-toolchain/argocd-mcp-server/internal/server"
1414
"github.com/modelcontextprotocol/go-sdk/mcp"
15+
"github.com/prometheus/client_golang/prometheus/promhttp"
1516
"github.com/spf13/cobra"
1617
)
1718

@@ -58,7 +59,7 @@ var startServerCmd = &cobra.Command{
5859
logger := slog.New(slog.NewTextHandler(cmd.ErrOrStderr(), &slog.HandlerOptions{
5960
Level: lvl,
6061
}))
61-
logger.Info("starting the Argo CD MCP server", "transport", transport, "url", argocdURL, "insecure", argocdInsecure, "debug", debug)
62+
logger.Info("starting the Argo CD MCP server", "transport", transport, "argocd-url", argocdURL, "insecure", argocdInsecure, "debug", debug)
6263
if debug {
6364
lvl.Set(slog.LevelDebug)
6465
logger.Debug("debug mode enabled")
@@ -76,11 +77,16 @@ var startServerCmd = &cobra.Command{
7677
}
7778
default:
7879
mux := http.NewServeMux()
80+
7981
// MCP endpoint
8082
mux.Handle("/mcp", mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
8183
return srv
8284
}, nil))
83-
// HealthCheck endpoint.
85+
86+
// Metrics endpoint
87+
mux.Handle("/metrics", promhttp.Handler())
88+
89+
// HealthCheck endpoint
8490
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
8591
w.Header().Set("Content-Type", "application/json")
8692
json.NewEncoder(w).Encode(map[string]string{ //nolint:errcheck

go.mod

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@ go 1.24.6
55
require (
66
github.com/argoproj/argo-cd/v3 v3.0.19
77
github.com/argoproj/gitops-engine v0.7.1-0.20250905153922-d96c3d51e4c4
8+
github.com/codeready-toolchain/toolchain-e2e v0.0.0-20251219102801-c4598f6cc695
89
github.com/google/jsonschema-go v0.3.0
910
github.com/h2non/gock v1.2.0
1011
github.com/modelcontextprotocol/go-sdk v1.2.0
12+
github.com/prometheus/client_golang v1.22.0
1113
github.com/spf13/cobra v1.10.1
1214
github.com/stretchr/testify v1.10.0
1315
k8s.io/apimachinery v0.33.0
16+
k8s.io/client-go v0.33.0
1417
)
1518

16-
// replace github.com/codeready-toolchain/converse-mcp => ../converse-mcp
19+
replace github.com/codeready-toolchain/toolchain-e2e => github.com/xcoulon/toolchain-e2e v0.0.0-20260105134512-e9877648a5f9
1720

1821
require (
1922
cloud.google.com/go/compute/metadata v0.6.0 // indirect
@@ -43,7 +46,7 @@ require (
4346
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
4447
github.com/distribution/reference v0.6.0 // indirect
4548
github.com/dlclark/regexp2 v1.11.5 // indirect
46-
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
49+
github.com/emicklei/go-restful/v3 v3.11.2 // indirect
4750
github.com/emirpasic/gods v1.18.1 // indirect
4851
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
4952
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
@@ -105,7 +108,6 @@ require (
105108
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
106109
github.com/pkg/errors v0.9.1 // indirect
107110
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
108-
github.com/prometheus/client_golang v1.22.0 // indirect
109111
github.com/prometheus/client_model v0.6.1 // indirect
110112
github.com/prometheus/common v0.62.0 // indirect
111113
github.com/prometheus/procfs v0.15.1 // indirect
@@ -144,15 +146,14 @@ require (
144146
k8s.io/api v0.33.0 // indirect
145147
k8s.io/apiextensions-apiserver v0.33.0 // indirect
146148
k8s.io/apiserver v0.33.0 // indirect
147-
k8s.io/cli-runtime v0.32.2 // indirect
148-
k8s.io/client-go v0.33.0 // indirect
149+
k8s.io/cli-runtime v0.32.3 // indirect
149150
k8s.io/component-base v0.33.0 // indirect
150151
k8s.io/component-helpers v0.32.2 // indirect
151152
k8s.io/controller-manager v0.0.0 // indirect
152153
k8s.io/klog/v2 v2.130.1 // indirect
153154
k8s.io/kube-aggregator v0.32.2 // indirect
154155
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
155-
k8s.io/kubectl v0.32.2 // indirect
156+
k8s.io/kubectl v0.32.3 // indirect
156157
k8s.io/kubernetes v1.32.7 // indirect
157158
k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect
158159
oras.land/oras-go/v2 v2.5.0 // indirect

go.sum

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
8787
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
8888
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
8989
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
90-
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
91-
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
90+
github.com/emicklei/go-restful/v3 v3.11.2 h1:1onLa9DcsMYO9P+CXaL0dStDqQ2EHHXLiz+BtnqkLAU=
91+
github.com/emicklei/go-restful/v3 v3.11.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
9292
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
9393
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
9494
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
@@ -358,6 +358,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
358358
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
359359
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
360360
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
361+
github.com/xcoulon/toolchain-e2e v0.0.0-20260105134512-e9877648a5f9 h1:xZ7tZnlHuEfTCDze1Jg1hZBHjrYuAmatvzXEpODtStU=
362+
github.com/xcoulon/toolchain-e2e v0.0.0-20260105134512-e9877648a5f9/go.mod h1:aiLutGqGRyz7ypAyPcUgiorOFytTCoX2Viz7gZ3150E=
361363
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
362364
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
363365
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=

internal/metrics/metrics.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package metrics
2+
3+
import (
4+
"github.com/prometheus/client_golang/prometheus"
5+
"github.com/prometheus/client_golang/prometheus/promauto"
6+
)
7+
8+
var (
9+
10+
// ToolCallsTotal counts total tool invocation by tool name
11+
MCPCallsTotal = promauto.NewCounterVec(
12+
prometheus.CounterOpts{
13+
Name: "mcp_calls_total",
14+
Help: "Total number of MCP calls",
15+
},
16+
[]string{"method", "name", "success"},
17+
)
18+
19+
MCPCallDuration = promauto.NewHistogramVec(
20+
prometheus.HistogramOpts{
21+
Name: "mcp_call_duration_seconds",
22+
Help: "Duration of MCP calls in seconds",
23+
Buckets: prometheus.DefBuckets,
24+
},
25+
[]string{"method", "name", "success"},
26+
)
27+
)

internal/server/middleware.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"strconv"
7+
"time"
8+
9+
"github.com/codeready-toolchain/argocd-mcp-server/internal/metrics"
10+
"github.com/modelcontextprotocol/go-sdk/mcp"
11+
)
12+
13+
func NewMetricsMiddleware(logger *slog.Logger) mcp.Middleware {
14+
return func(next mcp.MethodHandler) mcp.MethodHandler {
15+
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
16+
logger.Debug("metrics-middleware: received request", "method", method, "params", req.GetParams())
17+
// measure the duration of the request
18+
start := time.Now()
19+
// call the next middleware
20+
result, err := next(ctx, method, req)
21+
// measure the duration of the request
22+
duration := time.Since(start)
23+
var tool string
24+
if p, ok := req.GetParams().(*mcp.CallToolParamsRaw); ok {
25+
tool = p.Name
26+
}
27+
success := err == nil
28+
if r, ok := result.(*mcp.CallToolResult); ok {
29+
logger.Debug("metrics-middleware: call tool result", "is-error", r.IsError)
30+
success = success && !r.IsError
31+
}
32+
// increment/update the metrics
33+
metrics.MCPCallsTotal.WithLabelValues(method, tool, strconv.FormatBool(success)).Inc()
34+
metrics.MCPCallDuration.WithLabelValues(method, tool, strconv.FormatBool(success)).Observe(float64(duration.Milliseconds()))
35+
return result, err
36+
}
37+
}
38+
}

internal/server/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ func New(logger *slog.Logger, cl *argocd.Client) *mcp.Server {
2424
)
2525

2626
s.AddPrompt(argocd.UnhealthyResourcesPrompt, argocd.UnhealthyApplicationResourcesPromptHandle(logger, cl))
27+
s.AddReceivingMiddleware(NewMetricsMiddleware(logger))
2728
mcp.AddTool(s, argocd.UnhealthyApplicationsTool, argocd.UnhealthyApplicationsToolHandle(logger, cl))
2829
mcp.AddTool(s, argocd.UnhealthyApplicationResourcesTool, argocd.UnhealthyApplicationResourcesToolHandle(logger, cl))
2930
return s

test/e2e/server_test.go

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ import (
1212
"testing"
1313
"time"
1414

15+
toolchaintests "github.com/codeready-toolchain/toolchain-e2e/testsupport/metrics"
16+
1517
argocdv3 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
1618
"github.com/codeready-toolchain/argocd-mcp-server/internal/argocd"
1719
"github.com/modelcontextprotocol/go-sdk/mcp"
1820
"github.com/stretchr/testify/assert"
1921
"github.com/stretchr/testify/require"
2022
"k8s.io/apimachinery/pkg/runtime"
23+
"k8s.io/client-go/rest"
2124
)
2225

2326
// ------------------------------------------------------------------------------------------------
@@ -100,6 +103,14 @@ func TestServer(t *testing.T) {
100103
err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.StructuredContent.(map[string]any), &actualStructuredContent)
101104
require.NoError(t, err)
102105
assert.Equal(t, expectedContent, actualStructuredContent)
106+
// also, check the metrics when the server runs on HTTP
107+
if td.name == "http" {
108+
// get the metrics
109+
metric, err := toolchaintests.GetMetricValue(&rest.Config{}, "http://"+MCPServerListen, `mcp_calls_total`, []string{"method", "tools/call", "name", "unhealthyApplications", "success", "true"})
110+
require.NoError(t, err)
111+
assert.InEpsilon(t, 1, metric, 0.001)
112+
}
113+
103114
})
104115

105116
t.Run("call/unhealthyApplicationResources/ok", func(t *testing.T) {
@@ -161,6 +172,12 @@ func TestServer(t *testing.T) {
161172
err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.StructuredContent.(map[string]any), &actualStructuredContent)
162173
require.NoError(t, err)
163174
assert.Equal(t, expectedContent, actualStructuredContent)
175+
if td.name == "http" {
176+
// get the metrics
177+
metric, err := toolchaintests.GetMetricValue(&rest.Config{}, "http://"+MCPServerListen, `mcp_calls_total`, []string{"method", "tools/call", "name", "unhealthyApplicationResources", "success", "true"})
178+
require.NoError(t, err)
179+
assert.InEpsilon(t, 1, metric, 0.001)
180+
}
164181
})
165182

166183
t.Run("call/unhealthyApplicationResources/argocd-error", func(t *testing.T) {
@@ -175,6 +192,12 @@ func TestServer(t *testing.T) {
175192
// then
176193
require.NoError(t, err)
177194
assert.True(t, result.IsError)
195+
if td.name == "http" {
196+
// get the metrics
197+
metric, err := toolchaintests.GetMetricValue(&rest.Config{}, "http://"+MCPServerListen, `mcp_calls_total`, []string{"method", "tools/call", "name", "unhealthyApplicationResources", "success", "false"})
198+
require.NoError(t, err)
199+
assert.InEpsilon(t, 1, metric, 0.001)
200+
}
178201
})
179202
})
180203
}
@@ -184,11 +207,11 @@ func TestServer(t *testing.T) {
184207
init func(*testing.T) (*mcp.ClientSession, KillMCPServerFunc)
185208
}{
186209
{
187-
name: "stdio",
210+
name: "stdio-unreachable",
188211
init: newStdioSession(MCPServerListen, MCPServerDebug, "http://localhost:50085", "another-token"), // invalid URL and token for the Argo CD server
189212
},
190213
{
191-
name: "http",
214+
name: "http-unreachable",
192215
init: newHTTPSession(MCPServerListen, MCPServerDebug, "http://localhost:50085", "another-token"), // invalid URL and token for the Argo CD server
193216
},
194217
}
@@ -223,7 +246,7 @@ func newStdioSession(mcpServerListenPort string, mcpServerDebug bool, argocdURL
223246
cmd := newServerCmd(ctx, "stdio", mcpServerListenPort, strconv.FormatBool(mcpServerDebug), argocdURL, argocdToken)
224247
cl := mcp.NewClient(&mcp.Implementation{Name: "e2e-test-client", Version: "v1.0.0"}, nil)
225248
session, err := cl.Connect(ctx, &mcp.CommandTransport{Command: cmd}, nil)
226-
require.NoError(t, err)
249+
require.NoError(t, err, "failed to connect to the MCP server with stdio transport: process exited with code=%v", cmd.ProcessState.ExitCode())
227250
return session, func() {
228251
// nothing to do
229252
}
@@ -234,17 +257,17 @@ func newHTTPSession(mcpServerListen string, mcpServerDebug bool, argocdURL strin
234257
return func(t *testing.T) (*mcp.ClientSession, KillMCPServerFunc) {
235258
ctx := context.Background()
236259
cmd := newServerCmd(ctx, "http", mcpServerListen, strconv.FormatBool(mcpServerDebug), argocdURL, argocdToken)
260+
cmd.Stderr = os.Stdout
237261
go func() {
238262
t.Logf("starting the MCP server: %v", cmd.String())
239263
if err := cmd.Run(); err != nil {
240264
exitErr := &exec.ExitError{}
241265
// Ignore expected exit error when the process is killed in teardown.
242266
if !errors.As(err, &exitErr) {
243-
t.Errorf("failed to run command: %v", err)
267+
t.Logf("failed to run command: %v", err)
244268
}
245269
}
246270
}()
247-
cmd.Stderr = os.Stdout
248271
t.Logf("waiting for the MCP server to start")
249272
err := waitForMCPServer(mcpServerListen)
250273
require.NoError(t, err, "failed to wait for the MCP server to start")

0 commit comments

Comments
 (0)