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
14 changes: 14 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

Release Notes.

## 0.2.0

### Features

* TLS certificate verification is now enforced for OAP connections. Added `--sw-insecure` flag to opt out (development/self-signed certs only).
* Sensitive fields (`authorization`, `password`, `token`, `secret`) are redacted in `--log-command` output.
* Environment variable references (`${VAR}`) in `--sw-username`/`--sw-password` now log a warning when the variable is not set, preventing silent unauthenticated requests.
* URL scheme validation rejects non-http/https OAP URLs.
* Regex patterns supplied to `list_mqe_metrics` are validated for complexity before compilation.
* Added `--allowed-origins` flag to `sse` and `streamable` transports for CORS origin enforcement. When unset (default), any `Origin` is reflected back so all browser origins work out of the box. When set, only listed origins receive CORS headers; all others get `403 Forbidden`. Use `*` as an entry to send the wildcard header explicitly.
* Increased reliability of core CLI commands through expanded automated test coverage.
* Removed an unused CLI tool and its associated parameter to simplify the interface and avoid confusion.
* Added validation for tool configuration properties, returning clear errors when required values are missing or invalid.

## 0.1.0

### Features
Expand Down
22 changes: 19 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,15 @@ The SkyWalking OAP URL is resolved in priority order:

SSE and HTTP transports always use the configured server URL.

Basic auth is configured via `--sw-username` / `--sw-password` flags. The startup flags support `${ENV_VAR}` syntax to resolve credentials from environment variables (e.g. `--sw-password ${MY_SECRET}`).
Basic auth is configured via `--sw-username` / `--sw-password` flags. The startup flags support `${ENV_VAR}` syntax to resolve credentials from environment variables (e.g. `--sw-password ${MY_SECRET}`). If a referenced env var is not set, a warning is logged and the credential is treated as empty.

Each transport injects the OAP URL and auth into the request context via `WithSkyWalkingURLAndInsecure()` and `WithSkyWalkingAuth()`. Tools extract them downstream using `skywalking-cli`'s `contextkey.BaseURL{}`, `contextkey.Username{}`, and `contextkey.Password{}`.
TLS verification is enforced by default. Use `--sw-insecure` to skip verification (development/self-signed certs only).

Each transport injects the OAP URL, insecure flag, and auth into the request context via `WithSkyWalkingURLAndInsecure()` and `WithSkyWalkingAuth()`. Tools extract them downstream using `skywalking-cli`'s `contextkey.BaseURL{}`, `contextkey.Insecure{}`, `contextkey.Username{}`, and `contextkey.Password{}`.

### CORS / CSRF (`internal/swmcp/cors.go`)

`sse` and `streamable` transports support `--allowed-origins` (comma-separated). When set, requests with an `Origin` header not in the list are rejected with `403 Forbidden`. CORS response headers are set for allowed origins. When the flag is empty (default), all origins are permitted. The middleware is injected via `WithHTTPServer` / `WithStreamableHTTPServer` so the MCP handler is wrapped rather than forked.

### Server Wiring (`internal/swmcp/server.go`)

Expand All @@ -60,9 +66,19 @@ Each transport injects the OAP URL and auth into the request context via `WithSk
### Communication with SkyWalking OAP

- **Most tools** use `skywalking-cli` packages (`pkg/graphql/...`) which communicate via GraphQL
- **MQE tools** use direct HTTP calls to the OAP `/graphql` endpoint
- **MQE tools** use direct HTTP calls to the OAP `/graphql` endpoint via `executeGraphQLWithContext()` in `mqe.go`. The HTTP client reads `contextkey.Insecure{}` to configure TLS and validates the URL scheme (`http`/`https` only) before each request.
- **Time handling**: `common.go` provides `BuildDurationWithContext()` and `GetTimeContext()` which fetch the OAP server's time/timezone for accurate duration calculations

### Input Validation (`internal/tools/mqe.go`)

All MQE tool inputs are validated before use:
- `validateMQETextField`: UTF-8, max length, no control characters — applied to all entity fields
- `validateLayerField`: additionally enforces `^[A-Z0-9_]+$` for `layer` / `dest_layer`
- `validateMQEExpression`: UTF-8, max 2048 chars, no control characters, max nesting depth 12
- `validateMetricName`: `^[A-Za-z0-9_.:-]+$` pattern, max 128 chars
- `validateRegexComplexity`: parses the regex AST via `regexp/syntax` and rejects patterns with >50 nodes
- `validateURLScheme` (`common.go`): rejects non-http/https OAP URLs before HTTP requests

## Extending the Server

### Adding a New Tool
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Available Commands:
stdio Start stdio server
streamable Start Streamable server

Flags:
Global Flags:
-h, --help help for swmcp
--log-command When true, log commands to the log file
--log-file string Path to log file
Expand All @@ -44,8 +44,19 @@ Flags:
--sw-url string Specify the OAP URL to connect to (e.g. http://localhost:12800)
--sw-username string Username for basic auth to SkyWalking OAP (supports ${ENV_VAR} syntax)
--sw-password string Password for basic auth to SkyWalking OAP (supports ${ENV_VAR} syntax)
--sw-insecure Skip TLS certificate verification for OAP connections (use only in development)
-v, --version version for swmcp

SSE-specific Flags:
--sse-address string Host and port for the SSE server (default "localhost:8000")
--base-path string Base path for the SSE server
--allowed-origins string Comma-separated list of allowed CORS origins. Empty reflects any origin (open CORS). Use * to send the wildcard header.

Streamable-specific Flags:
--address string Host and port for the Streamable HTTP server (default "localhost:8000")
--endpoint-path string Endpoint path for the Streamable HTTP server (default "/mcp")
--allowed-origins string Comma-separated list of allowed CORS origins. Empty reflects any origin (open CORS). Use * to send the wildcard header.

Use "swmcp [command] --help" for more information about a command.
```

Expand All @@ -61,8 +72,14 @@ bin/swmcp stdio --sw-url http://localhost:12800 --sw-username admin --sw-passwor
# with basic auth (password from environment variable)
bin/swmcp stdio --sw-url http://localhost:12800 --sw-username admin --sw-password '${SW_PASSWORD}'

# skip TLS verification (development only, e.g. self-signed certs)
bin/swmcp stdio --sw-url https://localhost:12800 --sw-insecure

# or use SSE server
bin/swmcp sse --sse-address localhost:8000 --base-path /mcp --sw-url http://localhost:12800

# restrict CORS to specific origins (SSE and streamable transports)
bin/swmcp streamable --sw-url http://localhost:12800 --allowed-origins "http://localhost:3000,https://app.example.com"
```

Transport URL behavior:
Expand Down
2 changes: 2 additions & 0 deletions cmd/skywalking-mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ func init() {
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 (supports ${ENV_VAR} syntax)")
rootCmd.PersistentFlags().String("sw-password", "", "Password for basic auth to SkyWalking OAP (supports ${ENV_VAR} syntax)")
rootCmd.PersistentFlags().Bool("sw-insecure", false, "Skip TLS certificate verification for OAP connections (use only in development)")
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")
Expand All @@ -68,6 +69,7 @@ func init() {
_ = 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("insecure", rootCmd.PersistentFlags().Lookup("sw-insecure"))
_ = 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"))
Expand Down
77 changes: 77 additions & 0 deletions internal/swmcp/cors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// 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 (
"net/http"
"strings"
)

// corsMiddleware adds CORS response headers and enforces origin validation.
// When allowedOrigins is empty, every request with an Origin header is
// reflected back — i.e., CORS is open and all browser origins work.
// When allowedOrigins is non-empty, only listed origins receive CORS headers;
// requests from any other origin receive 403 Forbidden. Use "*" as an entry
// to explicitly allow all origins via the wildcard header.
func corsMiddleware(allowedOrigins []string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
if len(allowedOrigins) == 0 || isOriginAllowed(origin, allowedOrigins) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept")
w.Header().Set("Vary", "Origin")
} else {
http.Error(w, "forbidden: origin not allowed", http.StatusForbidden)
return
}
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}

// isOriginAllowed reports whether origin is in the allowed list.
// The wildcard "*" matches any origin.
func isOriginAllowed(origin string, allowed []string) bool {
for _, a := range allowed {
if a == "*" || a == origin {
return true
}
}
return false
}

// parseAllowedOrigins splits a comma-separated list of origins.
func parseAllowedOrigins(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
if trimmed := strings.TrimSpace(p); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
Loading
Loading