This is an automated email from the ASF dual-hosted git repository. zhouzixin pushed a commit to branch fix in repository https://gitbox.apache.org/repos/asf/skywalking-mcp.git
commit 81d2676ab19f8873bb3e0b2d6b4edd6a32544e01 Author: Zixin Zhou <[email protected]> AuthorDate: Thu Jun 5 00:32:18 2025 +0800 polish the code --- Dockerfile | 61 ++++++++++++++++++++++++++++++ Makefile | 8 ++-- README.md | 94 +++++++++++++++++++++++++++++++++++++++++++++- cmd/skywalking-mcp/main.go | 10 +++-- go.mod | 2 +- go.sum | 21 +++++++++++ internal/config/config.go | 21 +++++++++++ internal/swmcp/server.go | 24 ++++++++++++ internal/swmcp/sse.go | 88 ++++++++++++++++++++++++++++++++++++++++--- internal/swmcp/stdio.go | 71 +++++++++++++++++++++++----------- 10 files changed, 365 insertions(+), 35 deletions(-) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3e67b72 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,61 @@ +# Licensed to the 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. +# The 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. + +# Build stage +FROM golang:1.24-bullseye AS builder + +# Default version +ARG VERSION="dev" + +# Set the working directory +WORKDIR /app + +# Copy go.mod and go.sum files +COPY go.mod go.sum ./ + +# Go get dependencies +RUN go mod tidy + +# Copy the source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 go build \ + -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -o bin/swmcp ./cmd/skywalking-mcp/main.go + +# Make a stage to run the app +FROM debian:bullseye-slim + +# Install ca-certificates for HTTPS requests +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +# Create a non-root user +RUN useradd -r -u 1000 -m skywalking-mcp + +# Set the working directory +WORKDIR /app + +# Copy the binary from the builder stage +COPY --from=builder --chown=1000:1000 /app/bin/swmcp /app/ + +# Use the non-root user +USER skywalking-mcp + +# Expose the port the app runs on +EXPOSE 8000 + +# Run the application, defaulting to SSE transport +ENTRYPOINT ["/app/swmcp", "stdio"] \ No newline at end of file diff --git a/Makefile b/Makefile index 269e3ad..5892dc8 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,6 @@ # VERSION_PATH=main -GIT_VERSION=$(shell git describe --tags --abbrev=0)-SNAPSHOT-$(shell git rev-parse --short HEAD) GIT_COMMIT=$(shell git rev-parse HEAD) BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") MKDIR_P = mkdir -p @@ -28,14 +27,17 @@ LICENSE_EYE = license-eye all: build ; .PHONY: build -build: +build: ## Build the binary. ${MKDIR_P} bin/ CGO_ENABLED=0 go build -ldflags "\ - -X ${VERSION_PATH}.version=${GIT_VERSION} \ -X ${VERSION_PATH}.commit=${GIT_COMMIT} \ -X ${VERSION_PATH}.date=${BUILD_DATE}" \ -o bin/swmcp cmd/skywalking-mcp/main.go +.PHONY: build-image +build-image: ## Build the Docker image. + docker build -t skywalking-mcp:latest . + $(GO_LINT): @$(GO_LINT) version > /dev/null 2>&1 || go install github.com/golangci/golangci-lint/cmd/[email protected] $(LICENSE_EYE): diff --git a/README.md b/README.md index bd82a70..3c73c89 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,99 @@ Apache SkyWalking MCP <img src="http://skywalking.apache.org/assets/logo.svg" alt="Sky Walking logo" height="90px" align="right" /> -**SkyWalking-MCP**: Apache SkyWalking MCP Server. +**SkyWalking-MCP**: A [Model Context Protocol][mcp] (MCP) server for integrating AI agents with Skywalking OAP and the surrounding ecosystem. **SkyWalking**: an APM(application performance monitor) system, especially designed for microservices, cloud native and container-based (Docker, Kubernetes, Mesos) architectures. +## Usage + +### From Source + +```bash +# Clone the repository +git clone https://github.com/apache/skywalking-mcp.git +cd skywalking-mcp && go mod tidy + +# Build the project +make +``` + +### Command-line Options + +```bash +Usage: + swmcp [command] + +Available Commands: + completion Generate the autocompletion script for the specified shell + help Help about any command + sse Start SSE server + stdio Start stdio server + +Flags: + -h, --help help for swmcp + --log-command When true, log commands to the log file + --log-file string Path to log file + --log-level string Logging level (debug, info, warn, error) (default "info") + --read-only Restrict the server to read-only operations + --sw-url string Specify the OAP URL to connect to (e.g. http://localhost:12800) + -v, --version version for swmcp + +Use "swmcp [command] --help" for more information about a command. +``` + +You could start the MCP server with the following command: + +```bash +# use stdio server +bin/swmcp stdio --sw-url http://localhost:12800 + +# or use SSE server +bin/swmcp sse --sse-address localhost:8000 --base-path /mcp --sw-url http://localhost:12800 +``` + +### Usage with Cursor + +```json +{ + "mcpServers": { + "skywalking": { + "command": "swmcp stdio", + "args": [ + "--sw-url", + "http://localhost:12800" + ] + } + } +} +``` + +If using Docker: + +`make build-image` to build the Docker image, then configure the MCP server like this: + +```json +{ + "mcpServers": { + "skywalking": { + "command": "docker", + "args": [ + "run", + "--rm", + "-i", + "-e", + "SW_URL", + "skywalking-mcp:latest" + ], + "env": { + "SW_URL": "http://localhost:12800" + } + } + } +} +``` + ## Contact Us * Submit [an issue](https://github.com/apache/skywalking/issues/new) by using [MCP] as title prefix. * Mail list: **[email protected]**. Mail to `[email protected]`, follow the reply to subscribe the mail list. @@ -15,4 +103,6 @@ microservices, cloud native and container-based (Docker, Kubernetes, Mesos) arch * Twitter, [ASFSkyWalking](https://twitter.com/ASFSkyWalking) ## License -[Apache 2.0 License.](/LICENSE) \ No newline at end of file +[Apache 2.0 License.](/LICENSE) + +[mcp]: https://modelcontextprotocol.io/ \ No newline at end of file diff --git a/cmd/skywalking-mcp/main.go b/cmd/skywalking-mcp/main.go index a4c0f22..0504f34 100644 --- a/cmd/skywalking-mcp/main.go +++ b/cmd/skywalking-mcp/main.go @@ -18,10 +18,13 @@ package main import ( + "context" "fmt" "log/slog" "os" + "os/signal" "strings" + "syscall" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -35,7 +38,7 @@ var ( date = "date" rootCmd = &cobra.Command{ - Use: "server", + Use: "swmcp", Short: "Apache SkyWalking MCP Server.", Long: `This is a server that implements the MCP protocol for Apache SkyWalking.`, Version: fmt.Sprintf("Version: %s\nCommit: %s\nBuild Date: %s", version, commit, date), @@ -54,7 +57,6 @@ 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("sse-addr", "localhost:8000", "Which address to listen on for SSE transport") 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") @@ -62,7 +64,6 @@ func init() { // Bind flag to viper _ = viper.BindPFlag("url", rootCmd.PersistentFlags().Lookup("sw-url")) - _ = viper.BindPFlag("sse-addr", rootCmd.PersistentFlags().Lookup("sse-addr")) _ = 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")) @@ -72,6 +73,9 @@ func init() { Level: parseLogLevel(viper.GetString("log-level")), }))) + _, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + // Add subcommands rootCmd.AddCommand(swmcp.NewStdioServer()) rootCmd.AddCommand(swmcp.NewSSEServer()) diff --git a/go.mod b/go.mod index 6682c1c..3f0da89 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/apache/skywalking-mcp go 1.24.3 require ( - github.com/apache/skywalking-cli v0.0.0-20250520051046-7bbac156e74e + github.com/apache/skywalking-cli v0.0.0-20250604010708-77b4c49e89c9 github.com/mark3labs/mcp-go v0.31.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 diff --git a/go.sum b/go.sum index f0e868f..f99c7a9 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/apache/skywalking-cli v0.0.0-20250520051046-7bbac156e74e h1:JRsFyYSDXtI1wCy1POoXSw4jpIhLFHp9Lqp+xFz7+6g= github.com/apache/skywalking-cli v0.0.0-20250520051046-7bbac156e74e/go.mod h1:5S2bH5p65WLXmFufyX5JM1btkx6ail8wqdSkjy0hb3I= +github.com/apache/skywalking-cli v0.0.0-20250604010708-77b4c49e89c9 h1:loGzKlrRMY5r4qoCFA+cWSEvCP4hj1dnf+BzrGSDKQE= +github.com/apache/skywalking-cli v0.0.0-20250604010708-77b4c49e89c9/go.mod h1:5S2bH5p65WLXmFufyX5JM1btkx6ail8wqdSkjy0hb3I= +github.com/apache/skywalking-cli v0.9.0 h1:qPLZqVii9rvS4TDKeWeqh80z1ZKpCdIiWEXFjlqEw2I= +github.com/apache/skywalking-cli v0.9.0/go.mod h1:daosZFeQYSvZlF43IQiIrWfU0nhD1WOu8YVnT5eudOU= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -17,6 +23,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -27,17 +34,26 @@ github.com/mark3labs/mcp-go v0.31.0 h1:4UxSV8aM770OPmTvaVe/b1rA2oZAjBMhGBfUgOGut github.com/mark3labs/mcp-go v0.31.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mum4k/termdash v0.10.0/go.mod h1:l3tO+lJi9LZqXRq7cu7h5/8rDIK3AzelSuq2v/KncxI= +github.com/nsf/termbox-go v0.0.0-20190817171036-93860e161317/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= +github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/olekukonko/tablewriter v0.0.2/go.mod h1:rSAaSIOAGT9odnlyGlUfAJaoc5w2fSBUmeGDbRWPxyQ= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= @@ -53,15 +69,19 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= @@ -70,6 +90,7 @@ golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 0a4fe2c..81a0f57 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -47,3 +47,24 @@ type StdioServerConfig struct { // LogCommands indicates if we should log commands LogCommands bool } + +// SSEServerConfig holds the configuration for Stdio. +type SSEServerConfig struct { + // SkyWalking OAP URL to target for API requests (e.g. localhost:12800) + URL string + + // ReadOnly indicates if we should only offer read-only tools + ReadOnly bool + + // Path to the log file if not stderr + LogFilePath string + + // LogCommands indicates if we should log commands + LogCommands bool + + // The host and port to start the sse server on + Address string + + // Base path for the sse server + BasePath string +} diff --git a/internal/swmcp/server.go b/internal/swmcp/server.go index ebdbaa0..880ce19 100644 --- a/internal/swmcp/server.go +++ b/internal/swmcp/server.go @@ -18,11 +18,17 @@ package swmcp import ( + "fmt" + "os" + "github.com/apache/skywalking-mcp/internal/tools" + "github.com/sirupsen/logrus" "github.com/mark3labs/mcp-go/server" ) +// newMcpServer creates a new MCP server instance, +// and we can add various tools and capabilities to it. func newMcpServer() *server.MCPServer { mcpServer := server.NewMCPServer( "skywalking-mcp", @@ -34,3 +40,21 @@ func newMcpServer() *server.MCPServer { return mcpServer } + +func initLogger(logFilePath string) (*logrus.Logger, error) { + if logFilePath == "" { + return logrus.New(), nil + } + + file, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return nil, fmt.Errorf("failed to open log file: %w", err) + } + + logrusLogger := logrus.New() + logrusLogger.SetFormatter(&logrus.TextFormatter{}) + logrusLogger.SetLevel(logrus.DebugLevel) + logrusLogger.SetOutput(file) + + return logrusLogger, nil +} diff --git a/internal/swmcp/sse.go b/internal/swmcp/sse.go index 6a63b77..040370d 100644 --- a/internal/swmcp/sse.go +++ b/internal/swmcp/sse.go @@ -18,27 +18,105 @@ package swmcp import ( + "context" + "errors" "fmt" + "log" + "net/http" "os" + "os/signal" + "syscall" + "time" + "github.com/apache/skywalking-mcp/internal/config" "github.com/mark3labs/mcp-go/server" "github.com/spf13/cobra" + "github.com/spf13/viper" ) func NewSSEServer() *cobra.Command { - return &cobra.Command{ + sseCmd := &cobra.Command{ Use: "sse", Short: "Start SSE server", Long: `Start a server that listens for Server-Sent Events (SSE) on the specified address.`, - Run: func(_ *cobra.Command, _ []string) { - if err := runSSEServer(); err != nil { - _, _ = fmt.Fprintf(os.Stderr, "failed to run sse server: %v\n", err) + RunE: func(cmd *cobra.Command, _ []string) error { + sseServerConfig := config.SSEServerConfig{ + Address: viper.GetString("sse-address"), + BasePath: viper.GetString("base-path"), } + + return runSSEServer(context.Background(), sseServerConfig) }, } + + // Add SSE server specific flags + sseCmd.Flags().String("sse-address", "localhost:8000", + "The host and port to start the sse server on") + sseCmd.Flags().String("base-path", "", + "Base path for the sse server") + _ = viper.BindPFlag("sse-address", sseCmd.Flags().Lookup("sse-address")) + _ = viper.BindPFlag("base-path", sseCmd.Flags().Lookup("base-path")) + + return sseCmd } // runSSEServer starts a server that listens for Server-Sent Events (SSE) on the specified address. -func runSSEServer() *server.SSEServer { +func runSSEServer(ctx context.Context, cfg config.SSEServerConfig) error { + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + + logger, err := initLogger(cfg.LogFilePath) + if err != nil { + log.Fatal("Failed to initialize logger:", err) + } + + sseServer := server.NewSSEServer( + newMcpServer(), + server.WithStaticBasePath(cfg.BasePath), + server.WithSSEContextFunc(EnhanceHTTPContextFunc()), + ) + ssePath := sseServer.CompleteSsePath() + log.Printf("Starting SkyWalking MCP server using SSE transport listening on http://%s%s\n ", cfg.Address, ssePath) + + errCh := make(chan error, 1) + go func() { + if err := sseServer.Start(cfg.Address); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err // bubble up real crashes + } + }() + + // Give the server a moment to start + time.Sleep(100 * time.Millisecond) + + // Block until Ctrl-C or an internal error + select { + case <-ctx.Done(): + // user hit Ctrl-C + _, _ = fmt.Fprintln(os.Stderr, "Received shutdown signal, stopping server...") + case err := <-errCh: + // HTTP server crashed + return fmt.Errorf("sse server error: %w", err) + } + + // Graceful shutdown + shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // First try to shut down the SSE server + if err := sseServer.Shutdown(shCtx); err != nil { + if !errors.Is(err, http.ErrServerClosed) { + logger.Errorf("Error shutting down SSE server: %v", err) + } + } + + // Wait for any remaining operations to complete + select { + case <-shCtx.Done(): + return fmt.Errorf("shutdown timed out") + case <-time.After(100 * time.Millisecond): + // Give a small grace period for cleanup + } + + _, _ = fmt.Fprintln(os.Stderr, "SSE server stopped gracefully") return nil } diff --git a/internal/swmcp/stdio.go b/internal/swmcp/stdio.go index c513c64..6ae4fcd 100644 --- a/internal/swmcp/stdio.go +++ b/internal/swmcp/stdio.go @@ -24,6 +24,7 @@ import ( "io" "log" "log/slog" + "net/http" "os" "os/signal" "strings" @@ -31,7 +32,6 @@ import ( "github.com/apache/skywalking-cli/pkg/contextkey" "github.com/mark3labs/mcp-go/server" - "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -66,22 +66,17 @@ func NewStdioServer() *cobra.Command { func runStdioServer(ctx context.Context, cfg config.StdioServerConfig) error { slog.Info("Start a server that communicates via standard input/output streams using JSON-RPC messages.") // Handle SIGINT and SIGTERM - _, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stop() stdioServer := server.NewStdioServer(newMcpServer()) - logrusLogger := logrus.New() - if cfg.LogFilePath != "" { - file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) - if err != nil { - return fmt.Errorf("failed to open log file: %w", err) - } - - logrusLogger.SetLevel(logrus.DebugLevel) - logrusLogger.SetOutput(file) + logger, err := initLogger(cfg.LogFilePath) + if err != nil { + log.Fatal("Failed to initialize logger:", err) } - stdLogger := log.New(logrusLogger.Writer(), "swmcp-stdioserver", 0) + + stdLogger := log.New(logger.Writer(), "swmcp-stdio-server", 0) stdioServer.SetErrorLogger(stdLogger) stdioServer.SetContextFunc(EnhanceStdioContextFunc()) @@ -91,7 +86,7 @@ func runStdioServer(ctx context.Context, cfg config.StdioServerConfig) error { in, out := io.Reader(os.Stdin), io.Writer(os.Stdout) if cfg.LogCommands { - loggedIO := tools.NewIOLogger(in, out, logrusLogger) + loggedIO := tools.NewIOLogger(in, out, logger) in, out = loggedIO, loggedIO } @@ -99,12 +94,12 @@ func runStdioServer(ctx context.Context, cfg config.StdioServerConfig) error { }() // Output github-mcp-server string - _, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on stdio\n") + _, _ = fmt.Fprintf(os.Stderr, "SkyWalking MCP Server running on stdio\n") // Wait for shutdown signal select { case <-ctx.Done(): - logrusLogger.Infof("shutting down server...") + logger.Infof("shutting down server...") case err := <-errC: if err != nil { return fmt.Errorf("error running server: %w", err) @@ -114,16 +109,33 @@ func runStdioServer(ctx context.Context, cfg config.StdioServerConfig) error { return nil } -var ExtractSWInfoFromCfg server.StdioContextFunc = func(ctx context.Context) context.Context { +var ExtractSWURLFromCfg server.StdioContextFunc = func(ctx context.Context) context.Context { urlStr := viper.GetString("url") if urlStr == "" { urlStr = config.DefaultSWURL } + // we need to ensure the URL ends with "/graphql" if !strings.HasSuffix(urlStr, "/graphql") { urlStr = strings.TrimRight(urlStr, "/") + "/graphql" } - return WithSkyWalkingURL(ctx, urlStr) + return WithSkyWalkingURLAndInsecure(ctx, urlStr) +} + +var ExtractSWURLFromHeaders server.SSEContextFunc = func(ctx context.Context, req *http.Request) context.Context { + urlStr := req.Header.Get("SW-URL") + if urlStr == "" { + urlStr = viper.GetString("url") + if urlStr == "" { + urlStr = config.DefaultSWURL + } + } + + // we need to ensure the URL ends with "/graphql" + if !strings.HasSuffix(urlStr, "/graphql") { + urlStr = strings.TrimRight(urlStr, "/") + "/graphql" + } + return WithSkyWalkingURLAndInsecure(ctx, urlStr) } func EnhanceStdioContextFuncs(funcs ...server.StdioContextFunc) server.StdioContextFunc { @@ -135,11 +147,28 @@ func EnhanceStdioContextFuncs(funcs ...server.StdioContextFunc) server.StdioCont } } -// WithSkyWalkingURL adds the SkyWalking URL to the context. -func WithSkyWalkingURL(ctx context.Context, url string) context.Context { - return context.WithValue(ctx, contextkey.BaseURL{}, url) +func EnhanceSSEContextFuncs(funcs ...server.SSEContextFunc) server.SSEContextFunc { + return func(ctx context.Context, r *http.Request) context.Context { + for _, f := range funcs { + ctx = f(ctx, r) + } + return ctx + } +} + +// WithSkyWalkingURLAndInsecure adds the SkyWalking URL and Insecure to the context. +func WithSkyWalkingURLAndInsecure(ctx context.Context, url string) context.Context { + ctx = context.WithValue(ctx, contextkey.BaseURL{}, url) + ctx = context.WithValue(ctx, contextkey.Insecure{}, false) + return ctx } +// EnhanceStdioContextFunc returns a StdioContextFunc that composes all the provided StdioContextFuncs. func EnhanceStdioContextFunc() server.StdioContextFunc { - return EnhanceStdioContextFuncs(ExtractSWInfoFromCfg) + return EnhanceStdioContextFuncs(ExtractSWURLFromCfg) +} + +// EnhanceHTTPContextFunc returns a SSEContextFunc that composes all the provided HTTPContextFuncs. +func EnhanceHTTPContextFunc() server.SSEContextFunc { + return EnhanceSSEContextFuncs(ExtractSWURLFromHeaders) }
