This is an automated email from the ASF dual-hosted git repository. wusheng pushed a commit to branch feat/session-skywalking-url in repository https://gitbox.apache.org/repos/asf/skywalking-mcp.git
commit 06dab7ea244f1f7ee616a05bfe13ae6b81c79629 Author: Wu Sheng <[email protected]> AuthorDate: Fri Mar 13 17:48:57 2026 +0800 feat: add session-based SkyWalking URL and basic auth support Allow AI agents to configure the SkyWalking OAP connection at runtime via a new set_skywalking_url tool, supporting per-session URL and basic auth overrides. MQE tools now read connection settings from context instead of global viper state, and --sw-url is no longer mandatory for stdio mode. Co-Authored-By: Claude Opus 4.6 <[email protected]> --- cmd/skywalking-mcp/main.go | 4 ++ internal/swmcp/server.go | 85 ++++++++++++++++++++++------ internal/swmcp/session.go | 134 +++++++++++++++++++++++++++++++++++++++++++++ internal/swmcp/stdio.go | 8 +-- internal/tools/mqe.go | 35 +++++++++--- 5 files changed, 233 insertions(+), 33 deletions(-) diff --git a/cmd/skywalking-mcp/main.go b/cmd/skywalking-mcp/main.go index 8ad4014..2db219d 100644 --- a/cmd/skywalking-mcp/main.go +++ b/cmd/skywalking-mcp/main.go @@ -57,6 +57,8 @@ func init() { // Add global Flags rootCmd.PersistentFlags().String("sw-url", "", "Specify the OAP URL to connect to (e.g. http://localhost:12800)") + rootCmd.PersistentFlags().String("sw-username", "", "Username for basic auth to SkyWalking OAP") + rootCmd.PersistentFlags().String("sw-password", "", "Password for basic auth to SkyWalking OAP") rootCmd.PersistentFlags().String("log-level", "info", "Logging level (debug, info, warn, error)") rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations") rootCmd.PersistentFlags().Bool("log-command", false, "When true, log commands to the log file") @@ -64,6 +66,8 @@ func init() { // Bind flag to viper _ = viper.BindPFlag("url", rootCmd.PersistentFlags().Lookup("sw-url")) + _ = viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("sw-username")) + _ = viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("sw-password")) _ = viper.BindPFlag("log-level", rootCmd.PersistentFlags().Lookup("log-level")) _ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only")) _ = viper.BindPFlag("log-command", rootCmd.PersistentFlags().Lookup("log-command")) diff --git a/internal/swmcp/server.go b/internal/swmcp/server.go index 21edfbc..ec052b8 100644 --- a/internal/swmcp/server.go +++ b/internal/swmcp/server.go @@ -43,6 +43,7 @@ func newMCPServer() *server.MCPServer { server.WithPromptCapabilities(true), server.WithLogging(), ) + AddSessionTools(s) tools.AddTraceTools(s) tools.AddLogTools(s) tools.AddMQETools(s) @@ -73,14 +74,21 @@ func initLogger(logFilePath string) (*logrus.Logger, error) { return logrusLogger, nil } -// WithSkyWalkingURLAndInsecure adds SkyWalking URL and insecure flag to the context -// This ensures all downstream requests will have contextkey.BaseURL{} and contextkey.Insecure{} +// WithSkyWalkingURLAndInsecure adds SkyWalking URL and insecure flag to the context. +// This ensures all downstream requests will have contextkey.BaseURL{} and contextkey.Insecure{}. func WithSkyWalkingURLAndInsecure(ctx context.Context, url string, insecure bool) context.Context { ctx = context.WithValue(ctx, contextkey.BaseURL{}, url) ctx = context.WithValue(ctx, contextkey.Insecure{}, insecure) return ctx } +// WithSkyWalkingAuth adds username and password to the context for basic auth. +func WithSkyWalkingAuth(ctx context.Context, username, password string) context.Context { + ctx = context.WithValue(ctx, contextkey.Username{}, username) + ctx = context.WithValue(ctx, contextkey.Password{}, password) + return ctx +} + // configuredSkyWalkingURL returns the configured SkyWalking OAP URL. // The value is sourced from the CLI/config binding for `--sw-url`, // falling back to the built-in default when unset. @@ -92,6 +100,20 @@ func configuredSkyWalkingURL() string { return tools.FinalizeURL(urlStr) } +// configuredAuth returns the configured username and password from CLI flags or env vars. +func configuredAuth() (username, password string) { + return viper.GetString("username"), viper.GetString("password") +} + +// withConfiguredAuth injects the configured auth credentials into the context if present. +func withConfiguredAuth(ctx context.Context) context.Context { + username, password := configuredAuth() + if username != "" { + ctx = WithSkyWalkingAuth(ctx, username, password) + } + return ctx +} + // urlFromHeaders extracts URL for a request. // URL is sourced from Header > configured value > Default. func urlFromHeaders(req *http.Request) string { @@ -103,32 +125,59 @@ func urlFromHeaders(req *http.Request) string { return tools.FinalizeURL(urlStr) } -// WithSkyWalkingContextFromConfig injects the SkyWalking URL and insecure -// settings from global configuration into the context. -var WithSkyWalkingContextFromConfig server.StdioContextFunc = func(ctx context.Context) context.Context { - return WithSkyWalkingURLAndInsecure(ctx, configuredSkyWalkingURL(), false) -} - -// withSkyWalkingContextFromRequest is the shared logic for enriching context from an http.Request. -func withSkyWalkingContextFromRequest(ctx context.Context, req *http.Request) context.Context { - urlStr := urlFromHeaders(req) - return WithSkyWalkingURLAndInsecure(ctx, urlStr, false) +// applySessionOverrides checks for a session in the context and applies any +// URL or auth overrides that were set via the set_skywalking_url tool. +func applySessionOverrides(ctx context.Context) context.Context { + session := SessionFromContext(ctx) + if session == nil { + return ctx + } + if url := session.URL(); url != "" { + ctx = context.WithValue(ctx, contextkey.BaseURL{}, url) + } + if username := session.Username(); username != "" { + ctx = WithSkyWalkingAuth(ctx, username, session.Password()) + } + return ctx } // EnhanceStdioContextFunc returns a StdioContextFunc that enriches the context -// with SkyWalking settings from the global configuration. +// with SkyWalking settings from the global configuration and a per-session store. func EnhanceStdioContextFunc() server.StdioContextFunc { - return WithSkyWalkingContextFromConfig + session := &Session{} + return func(ctx context.Context) context.Context { + ctx = WithSession(ctx, session) + ctx = WithSkyWalkingURLAndInsecure(ctx, configuredSkyWalkingURL(), false) + ctx = withConfiguredAuth(ctx) + ctx = applySessionOverrides(ctx) + return ctx + } } // EnhanceSSEContextFunc returns a SSEContextFunc that enriches the context -// with SkyWalking settings from SSE request headers. +// with SkyWalking settings from SSE request headers and a per-session store. func EnhanceSSEContextFunc() server.SSEContextFunc { - return withSkyWalkingContextFromRequest + session := &Session{} + return func(ctx context.Context, req *http.Request) context.Context { + ctx = WithSession(ctx, session) + urlStr := urlFromHeaders(req) + ctx = WithSkyWalkingURLAndInsecure(ctx, urlStr, false) + ctx = withConfiguredAuth(ctx) + ctx = applySessionOverrides(ctx) + return ctx + } } // EnhanceHTTPContextFunc returns a HTTPContextFunc that enriches the context -// with SkyWalking settings from HTTP request headers. +// with SkyWalking settings from HTTP request headers and a per-session store. func EnhanceHTTPContextFunc() server.HTTPContextFunc { - return withSkyWalkingContextFromRequest + session := &Session{} + return func(ctx context.Context, req *http.Request) context.Context { + ctx = WithSession(ctx, session) + urlStr := urlFromHeaders(req) + ctx = WithSkyWalkingURLAndInsecure(ctx, urlStr, false) + ctx = withConfiguredAuth(ctx) + ctx = applySessionOverrides(ctx) + return ctx + } } diff --git a/internal/swmcp/session.go b/internal/swmcp/session.go new file mode 100644 index 0000000..f9c4f27 --- /dev/null +++ b/internal/swmcp/session.go @@ -0,0 +1,134 @@ +// Licensed to Apache Software Foundation (ASF) under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Apache Software Foundation (ASF) licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package swmcp + +import ( + "context" + "fmt" + "sync" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/apache/skywalking-mcp/internal/tools" +) + +// sessionKey is the context key for looking up the session store. +type sessionKey struct{} + +// Session holds per-session SkyWalking connection configuration. +type Session struct { + mu sync.RWMutex + url string + username string + password string +} + +// SetConnection updates the session's connection parameters. +func (s *Session) SetConnection(url, username, password string) { + s.mu.Lock() + defer s.mu.Unlock() + s.url = url + s.username = username + s.password = password +} + +// URL returns the session's configured URL, or empty if not set. +func (s *Session) URL() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.url +} + +// Username returns the session's configured username. +func (s *Session) Username() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.username +} + +// Password returns the session's configured password. +func (s *Session) Password() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.password +} + +// SessionFromContext retrieves the session from the context, or nil if not present. +func SessionFromContext(ctx context.Context) *Session { + s, _ := ctx.Value(sessionKey{}).(*Session) + return s +} + +// WithSession attaches a session to the context. +func WithSession(ctx context.Context, s *Session) context.Context { + return context.WithValue(ctx, sessionKey{}, s) +} + +// SetSkyWalkingURLRequest represents the request for the set_skywalking_url tool. +type SetSkyWalkingURLRequest struct { + URL string `json:"url"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` +} + +func setSkyWalkingURL(ctx context.Context, req *SetSkyWalkingURLRequest) (*mcp.CallToolResult, error) { + if req.URL == "" { + return mcp.NewToolResultError("url is required"), nil + } + + session := SessionFromContext(ctx) + if session == nil { + return mcp.NewToolResultError("session not available"), nil + } + + finalURL := tools.FinalizeURL(req.URL) + session.SetConnection(finalURL, req.Username, req.Password) + + msg := fmt.Sprintf("SkyWalking URL set to %s", finalURL) + if req.Username != "" { + msg += " with basic auth credentials" + } + return mcp.NewToolResultText(msg), nil +} + +// AddSessionTools registers session management tools with the MCP server. +func AddSessionTools(s *server.MCPServer) { + tool := tools.NewTool( + "set_skywalking_url", + `Set the SkyWalking OAP server URL and optional basic auth credentials for this session. + +This tool configures the connection to SkyWalking OAP for all subsequent tool calls in the current session. +The URL and credentials persist for the lifetime of the session. + +Priority: session URL (set by this tool) > --sw-url flag > SW_URL env > default (localhost:12800) + +Examples: +- {"url": "http://demo.skywalking.apache.org:12800"}: Connect without auth +- {"url": "http://oap.internal:12800", "username": "admin", "password": "admin"}: Connect with basic auth +- {"url": "https://skywalking.example.com:443", "username": "skywalking", "password": "skywalking"}: HTTPS with auth`, + setSkyWalkingURL, + mcp.WithString("url", mcp.Required(), + mcp.Description("SkyWalking OAP server URL (required). Example: http://localhost:12800")), + mcp.WithString("username", + mcp.Description("Username for basic auth (optional)")), + mcp.WithString("password", + mcp.Description("Password for basic auth (optional)")), + ) + tool.Register(s) +} diff --git a/internal/swmcp/stdio.go b/internal/swmcp/stdio.go index 33a3529..02abb4a 100644 --- a/internal/swmcp/stdio.go +++ b/internal/swmcp/stdio.go @@ -19,7 +19,6 @@ package swmcp import ( "context" - "errors" "fmt" "io" "log" @@ -42,13 +41,8 @@ func NewStdioServer() *cobra.Command { Short: "Start stdio server", Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`, RunE: func(_ *cobra.Command, _ []string) error { - url := viper.GetString("url") - if url == "" { - return errors.New("--sw-url must be specified") - } - stdioServerConfig := config.StdioServerConfig{ - URL: url, + URL: viper.GetString("url"), ReadOnly: viper.GetBool("read-only"), LogFilePath: viper.GetString("log-file"), LogCommands: viper.GetBool("log-command"), diff --git a/internal/tools/mqe.go b/internal/tools/mqe.go index f152d49..0f4189a 100644 --- a/internal/tools/mqe.go +++ b/internal/tools/mqe.go @@ -20,6 +20,7 @@ package tools import ( "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -29,7 +30,8 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" - "github.com/spf13/viper" + + "github.com/apache/skywalking-cli/pkg/contextkey" ) // AddMQETools registers MQE-related tools with the MCP server @@ -53,8 +55,17 @@ type GraphQLResponse struct { } `json:"errors,omitempty"` } -// executeGraphQL executes a GraphQL query against SkyWalking OAP -func executeGraphQL(ctx context.Context, url, query string, variables map[string]interface{}) (*GraphQLResponse, error) { +// getContextString safely extracts a string value from context. +func getContextString(ctx context.Context, key any) string { + if v, ok := ctx.Value(key).(string); ok { + return v + } + return "" +} + +// executeGraphQLWithContext executes a GraphQL query using URL and auth from context. +func executeGraphQLWithContext(ctx context.Context, query string, variables map[string]interface{}) (*GraphQLResponse, error) { + url := getContextString(ctx, contextkey.BaseURL{}) url = FinalizeURL(url) reqBody := GraphQLRequest{ @@ -74,6 +85,14 @@ func executeGraphQL(ctx context.Context, url, query string, variables map[string req.Header.Set("Content-Type", "application/json") + // Add basic auth from context if present + username := getContextString(ctx, contextkey.Username{}) + password := getContextString(ctx, contextkey.Password{}) + if username != "" && password != "" { + auth := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) + req.Header.Set("Authorization", auth) + } + client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { @@ -186,7 +205,7 @@ func getServiceByName(ctx context.Context, serviceName, layer string) (*bool, er "serviceId": serviceID, } - result, err := executeGraphQL(ctx, viper.GetString("url"), query, variables) + result, err := executeGraphQLWithContext(ctx, query, variables) if err != nil { return nil, fmt.Errorf("failed to get service details: %w", err) } @@ -217,7 +236,7 @@ func findServiceID(ctx context.Context, serviceName, layer string) (string, erro "layer": layer, } - result, err := executeGraphQL(ctx, viper.GetString("url"), query, variables) + result, err := executeGraphQLWithContext(ctx, query, variables) if err != nil { return "", err } @@ -353,7 +372,7 @@ func executeMQEExpression(ctx context.Context, req *MQEExpressionRequest) (*mcp. "dumpDBRsp": req.DumpDBRsp, } - result, err := executeGraphQL(ctx, viper.GetString("url"), query, variables) + result, err := executeGraphQLWithContext(ctx, query, variables) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to execute MQE expression: %v", err)), nil } @@ -383,7 +402,7 @@ func listMQEMetrics(ctx context.Context, req *MQEMetricsListRequest) (*mcp.CallT variables["regex"] = req.Regex } - result, err := executeGraphQL(ctx, viper.GetString("url"), query, variables) + result, err := executeGraphQLWithContext(ctx, query, variables) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to list metrics: %v", err)), nil } @@ -431,7 +450,7 @@ func getMQEMetricsType(ctx context.Context, req *MQEMetricsTypeRequest) (*mcp.Ca "name": req.MetricName, } - result, err := executeGraphQL(ctx, viper.GetString("url"), query, variables) + result, err := executeGraphQLWithContext(ctx, query, variables) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to get metric type: %v", err)), nil }
