diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f82f308d..7ddcac76 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,7 +3,7 @@ "build": { "dockerfile": "Dockerfile", "args": { - "TOOLS_GO_VERSION": "1.25", + "TOOLS_GO_VERSION": "1.27", "TOOLS_ISTIO_VERSION": "1.28.3", "TOOLS_ARGO_ROLLOUTS_VERSION": "1.8.3", "TOOLS_KUBECTL_VERSION": "1.35.0", diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b9cbbe17..d98080bb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -55,6 +55,35 @@ jobs: run: | make test + plugin-verify: + env: + VERSION: v0.0.1-test + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + cache: false + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + version: v0.23.0 + use: 'true' + - name: Verify kagent Agent Plugin image + env: + DOCKER_BUILDKIT: 1 + BUILDX_BUILDER_NAME: kagent-builder-v0.23.0 + DOCKER_BUILD_ARGS: >- + --cache-from=type=gha + --load + --platform=linux/amd64 + DOCKER_REPO: "${{ github.repository_owner }}/tools" + DOCKER_BUILDER: "docker buildx" + run: make plugin-verify + go-e2e-tests: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 4b3d33a1..e4aa10cb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ bin/ /helm/kagent-tools/Chart.yaml /reports/tools-cve.csv .dagger/ + +# Plugin MCP config is a release artifact; keep it tracked even when a +# global gitignore excludes mcp.json files. +!plugin/mcp.json diff --git a/AGENTS.md b/AGENTS.md index ffe926f0..13ee3d57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ tools/ │ └── version/ # Version info ├── test/ │ └── e2e/ # End-to-end tests (Kind cluster) +├── plugin/ # kagent Agent Plugin sources (plugin.json, mcp.json, kubeconfig, skills/) ├── scripts/ # Build and setup scripts │ └── kind/ # Kind cluster configuration ├── helm/ # Helm chart for deployment @@ -73,7 +74,7 @@ tools/ │ └── tag.yaml # Release tagging ├── Makefile # Build orchestration ├── Dockerfile # Multi-stage build (multi-arch) -├── go.mod # Go 1.27.0 +├── go.mod # Go 1.27.3 ├── DEVELOPMENT.md # Development setup and standards └── CONTRIBUTION.md # Contribution process ``` @@ -359,6 +360,14 @@ Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `ci` 10. Add E2E tests if the tool interacts with a cluster. 11. Run `make fmt && make lint && make test` before submitting. +### Plugin bundle (kagent 1.x skills) + +The image doubles as a kagent Agent Plugin (see [docs/kagent-skills.md](docs/kagent-skills.md)). +New tool providers should add or extend a skill in `plugin/skills//SKILL.md` and keep +`cmd/plugin_bundle_test.go` passing; it checks manifests, `mcp.json`, skill frontmatter, and that +every tool a skill names is registered. Run `make plugin-verify` after changing the `Dockerfile` +or `plugin/`. + --- ## Additional Resources diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6a59d71b..38d45c10 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -6,7 +6,7 @@ This document provides comprehensive development guidelines for the KAgent Tools ### Required Tools -- **Go 1.24+** - Primary development language +- **Go 1.27+** - Primary development language - **Docker** - For containerization and testing - **Make** - Build automation - **Git** - Version control diff --git a/Dockerfile b/Dockerfile index c8c4882c..32803ac6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -80,30 +80,50 @@ COPY pkg pkg RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ --mount=type=cache,target=/root/.cache/go-build,rw \ echo "Building tool-server for $TARGETARCH on $BUILDARCH" && \ - CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -ldflags "$LDFLAGS" -o tool-server cmd/main.go + CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -trimpath -ldflags "-s -w $LDFLAGS" -o tool-server ./cmd + +### STAGE 3: assemble kagent Agent Plugin roots +FROM tools AS plugin +ARG VERSION=dev +COPY plugin /src/plugin +COPY --from=builder /workspace/tool-server /downloads/kagent-tools +RUN set -eu; \ + root=/out/opt/kagent-tools; skills_root=/out/opt/kagent-tools-skills; \ + plugin_version="${VERSION#v}"; \ + mkdir -p "$root/bin" "$root/config" "$skills_root" /out/usr/bin; \ + for binary in kagent-tools kubectl helm istioctl kubectl-argo-rollouts cilium; do \ + install -m 0755 "/downloads/$binary" "$root/bin/$binary"; \ + done; \ + sed "s/__VERSION__/${plugin_version}/" /src/plugin/plugin.json.tmpl > "$root/plugin.json"; \ + sed "s/__VERSION__/${plugin_version}/" /src/plugin/skills-plugin.json.tmpl > "$skills_root/plugin.json"; \ + install -m 0644 /src/plugin/mcp.json "$root/mcp.json"; \ + install -m 0644 /src/plugin/config/kubeconfig "$root/config/kubeconfig"; \ + cp -R /src/plugin/skills "$root/skills"; \ + cp -R /src/plugin/skills "$skills_root/skills"; \ + find /out/opt -type d -exec chmod 0755 {} +; \ + find /out/opt -type f ! -path '*/bin/*' -exec chmod 0644 {} +; \ + ln -s /opt/kagent-tools/bin/kagent-tools /out/tool-server; \ + for binary in kubectl helm istioctl kubectl-argo-rollouts cilium; do \ + ln -s "/opt/kagent-tools/bin/$binary" "/out/usr/bin/$binary"; \ + done; \ + chown -R 65532:65532 /out/opt -# Use distroless as minimal base image to package the manager binary -# Refer to https://github.com/GoogleContainerTools/distroless for more details FROM gcr.io/distroless/static:nonroot WORKDIR / USER 65532:65532 -ENV PATH=$PATH:/bin +ENV PATH=/opt/kagent-tools/bin:$PATH:/bin -# Copy the tools -COPY --from=tools --chown=65532:65532 /downloads/kubectl /bin/kubectl -COPY --from=tools --chown=65532:65532 /downloads/istioctl /bin/istioctl -COPY --from=tools --chown=65532:65532 /downloads/helm /bin/helm -COPY --from=tools --chown=65532:65532 /downloads/kubectl-argo-rollouts /bin/kubectl-argo-rollouts -COPY --from=tools --chown=65532:65532 /downloads/cilium /bin/cilium -# Copy the tool-server binary -COPY --from=builder --chown=65532:65532 /workspace/tool-server /tool-server +# /opt/kagent-tools -> kagent Agent Plugin root (CLIs + stdio MCP server + skills) +# /opt/kagent-tools-skills -> skills-only Agent Plugin root +# /tool-server, /bin/* -> backward-compatible symlinks (distroless /bin -> usr/bin) +COPY --from=plugin /out/ / ARG VERSION LABEL org.opencontainers.image.source=https://github.com/kagent-dev/tools -LABEL org.opencontainers.image.description="Kagent MCP tools server" +LABEL org.opencontainers.image.description="Kagent MCP tools server and kagent Agent Plugin (skills bundle)" LABEL org.opencontainers.image.authors="Kagent Creators 🤖" LABEL org.opencontainers.image.version="$VERSION" -ENTRYPOINT ["/tool-server"] \ No newline at end of file +ENTRYPOINT ["/opt/kagent-tools/bin/kagent-tools"] diff --git a/Makefile b/Makefile index 4f1e87da..b82be03a 100644 --- a/Makefile +++ b/Makefile @@ -161,6 +161,10 @@ buildx-create: docker-build: fmt buildx-create $(DOCKER_BUILDER) build $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -t $(TOOLS_IMG) -f Dockerfile ./ +.PHONY: plugin-verify +plugin-verify: docker-build ## Verify the image is a valid kagent Agent Plugin (layout, limits, stdio) + ./scripts/verify-plugin-image.sh $(TOOLS_IMG) + .PHONY: docker-build # build tools image for amd64 and arm64 docker-build-all: fmt buildx-create docker-build-all: DOCKER_BUILD_ARGS = --progress=plain --builder $(BUILDX_BUILDER_NAME) --platform linux/amd64,linux/arm64 --output type=tar,dest=/dev/null @@ -278,7 +282,7 @@ $(LOCALBIN): mkdir -p $(LOCALBIN) GOLANGCI_LINT = $(LOCALBIN)/golangci-lint -GOLANGCI_LINT_VERSION ?= v2.13.2 +GOLANGCI_LINT_VERSION ?= v2.15.0 .PHONY: golangci-lint golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. diff --git a/README.md b/README.md index 81a54677..aaa372bc 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,12 @@ helm upgrade -i -n kagent --create-namespace kagent-tools oci://ghcr.io/kagent-d helm ls -A ``` +- **kagent 1.x skills bundle (OCI image)** + +The same image is a kagent Agent Plugin: mount it in an `AgentTemplate` with +`source.oci` (digest-pinned) and `path: opt/kagent-tools` to get the MCP tools, all CLIs and +operational skills in one bundle. See [Using kagent-tools as a kagent 1.x skills bundle](docs/kagent-skills.md). + ## Quickstart Guide For a quickstart guide on how to run KAgent tools using AgentGateway, please refer to the [Quickstart Guide](docs/quickstart.md). diff --git a/cmd/main.go b/cmd/main.go index 2112444a..57e90c14 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -111,6 +111,23 @@ func run(cmd *cobra.Command, args []string) { metricsPort = port } + effectiveConfig, err := resolveServerConfig(serverFlags{ + Tools: tools, + ToolsSet: cmd.Flags().Changed("tools"), + ReadOnly: readOnly, + ReadOnlySet: cmd.Flags().Changed("read-only"), + Kubeconfig: *kubeconfig, + }, os.LookupEnv) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + tools, readOnly = effectiveConfig.Tools, effectiveConfig.ReadOnly + if err := applyServerEnvironment(effectiveConfig, stdio, os.Setenv); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + logger.Init(stdio) defer logger.Sync() @@ -121,7 +138,7 @@ func run(cmd *cobra.Command, args []string) { // Initialize OpenTelemetry tracing cfg := telemetry.LoadOtelCfg() - err := telemetry.SetupOTelSDK(ctx) + err = telemetry.SetupOTelSDK(ctx) if err != nil { logger.Get().Error("Failed to setup OpenTelemetry SDK", "error", err) os.Exit(1) @@ -155,7 +172,7 @@ func run(cmd *cobra.Command, args []string) { // labels are recorded as each provider registers its tools. mcpSrv.AddReceivingMiddleware(mcpserver.ToolMiddleware()) - registerMCP(mcpSrv, tools, *kubeconfig, readOnly) + registerMCP(mcpSrv, tools, effectiveConfig.Kubeconfig, readOnly) // Create wait group for server goroutines var wg sync.WaitGroup diff --git a/cmd/plugin_bundle_test.go b/cmd/plugin_bundle_test.go new file mode 100644 index 00000000..e244354d --- /dev/null +++ b/cmd/plugin_bundle_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" +) + +const pluginSourceDir = "../plugin" + +// Mirrors kagent go/core/pkg/agentplugins manifest and mcpServer types. +type pluginManifest struct { + Schema string `json:"$schema"` + Name string `json:"name"` + Version string `json:"version,omitempty"` + Description string `json:"description,omitempty"` + Homepage string `json:"homepage,omitempty"` + Repository string `json:"repository,omitempty"` + License string `json:"license,omitempty"` + Keywords []string `json:"keywords,omitempty"` +} + +type mcpDocument struct { + Schema string `json:"$schema"` + Servers map[string]json.RawMessage `json:"mcpServers"` +} + +type mcpStdioServer struct { + Type string `json:"type"` + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` + CWD string `json:"cwd,omitempty"` +} + +type skillFrontmatter struct { + Name string `json:"name"` + Description string `json:"description"` +} + +var ( + pluginNamePattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$`) + skillNamePattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + toolReference = regexp.MustCompile("`((?:k8s|helm|istio|argo|cilium|kubescape|prometheus|datetime)_[a-z_]+)`") +) + +func decodeStrict[Document pluginManifest | mcpDocument | mcpStdioServer](t *testing.T, raw []byte) Document { + t.Helper() + var document Document + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + require.NoError(t, decoder.Decode(&document)) + return document +} + +func TestPluginManifestsAreValid(t *testing.T) { + for _, file := range []string{"plugin.json.tmpl", "skills-plugin.json.tmpl"} { + raw, err := os.ReadFile(filepath.Join(pluginSourceDir, file)) + require.NoError(t, err) + manifest := decodeStrict[pluginManifest](t, raw) + require.Equal(t, "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", manifest.Schema, file) + require.Regexp(t, pluginNamePattern, manifest.Name, file) + require.LessOrEqual(t, len(manifest.Name), 64, file) + require.Equal(t, "__VERSION__", manifest.Version, file) + } +} + +func TestPluginMCPConfigIsValid(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(pluginSourceDir, "mcp.json")) + require.NoError(t, err) + document := decodeStrict[mcpDocument](t, raw) + require.Equal(t, "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", document.Schema) + require.Contains(t, document.Servers, "kagent-tools") + + server := decodeStrict[mcpStdioServer](t, document.Servers["kagent-tools"]) + require.Equal(t, "stdio", server.Type) + require.Equal(t, "./bin/kagent-tools", server.Command) + require.Contains(t, server.Args, "--stdio") + require.NotContains(t, server.Env, "PLUGIN_ROOT") + require.NotContains(t, server.Env, "PLUGIN_DATA") + require.NotContains(t, server.Env, envTools, "operators set this on the Harness; mcp.json env would override it") + require.NotContains(t, server.Env, envReadOnly, "operators set this on the Harness; mcp.json env would override it") + require.NotContains(t, server.Env, "KUBECONFIG", "use KAGENT_TOOLS_DEFAULT_KUBECONFIG so Harness KUBECONFIG still wins") + require.Equal(t, "${PLUGIN_ROOT}/config/kubeconfig", server.Env[envDefaultKubeconfig]) + require.FileExists(t, filepath.Join(pluginSourceDir, "config", "kubeconfig")) +} + +func registeredToolNames(t *testing.T) map[string]bool { + t.Helper() + ctx := context.Background() + server := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "plugin", Version: "test"}, nil) + registerMCP(server, nil, "", false) + serverTransport, clientTransport := sdkmcp.NewInMemoryTransports() + go func() { _ = server.Run(ctx, serverTransport) }() + client := sdkmcp.NewClient(&sdkmcp.Implementation{Name: "plugin-client", Version: "test"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + names := map[string]bool{} + for tool, err := range session.Tools(ctx, nil) { + require.NoError(t, err) + names[tool.Name] = true + } + return names +} + +func TestPluginSkillsAreValid(t *testing.T) { + skillDirs, err := os.ReadDir(filepath.Join(pluginSourceDir, "skills")) + require.NoError(t, err) + require.NotEmpty(t, skillDirs) + knownTools := registeredToolNames(t) + + for _, skillDir := range skillDirs { + require.True(t, skillDir.IsDir(), skillDir.Name()) + raw, err := os.ReadFile(filepath.Join(pluginSourceDir, "skills", skillDir.Name(), "SKILL.md")) + require.NoError(t, err, skillDir.Name()) + + content := string(raw) + require.True(t, strings.HasPrefix(content, "---\n"), "%s: missing frontmatter", skillDir.Name()) + frontmatterEnd := strings.Index(content[4:], "\n---\n") + require.Positive(t, frontmatterEnd, "%s: unterminated frontmatter", skillDir.Name()) + var frontmatter skillFrontmatter + require.NoError(t, yaml.Unmarshal([]byte(content[4:4+frontmatterEnd]), &frontmatter)) + + require.Equal(t, skillDir.Name(), frontmatter.Name) + require.Regexp(t, skillNamePattern, frontmatter.Name) + require.LessOrEqual(t, len(frontmatter.Name), 64) + require.NotEmpty(t, frontmatter.Description) + require.LessOrEqual(t, len(frontmatter.Description), 1024) + + references := toolReference.FindAllStringSubmatch(content, -1) + require.NotEmpty(t, references, "%s: skill must reference at least one MCP tool", skillDir.Name()) + for _, reference := range references { + require.Truef(t, knownTools[reference[1]], "%s references unknown tool %q", skillDir.Name(), reference[1]) + } + } +} diff --git a/cmd/plugin_image_test.go b/cmd/plugin_image_test.go new file mode 100644 index 00000000..699dba6d --- /dev/null +++ b/cmd/plugin_image_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + kagentMaxPackageEntries = 10_000 + kagentMaxPackageBytes = 100 << 20 +) + +type packageStats struct { + Entries int64 + Bytes int64 +} + +// walkPluginPackage mirrors kagent's validatePackage checks for one plugin root. +func walkPluginPackage(t *testing.T, root string) packageStats { + t.Helper() + // Resolve the root itself so in-root symlinks compare equal on hosts where + // the temp dir is behind a symlink (macOS /var -> /private/var). + root, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + var stats packageStats + err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + require.NoError(t, err) + stats.Entries++ + info, err := entry.Info() + require.NoError(t, err) + mode := info.Mode() + require.Truef(t, mode.IsRegular() || mode.IsDir() || mode&os.ModeSymlink != 0, "unsupported file %s (%s)", path, mode) + if mode.IsRegular() { + stats.Bytes += info.Size() + } + if mode&os.ModeSymlink != 0 { + resolved, err := filepath.EvalSymlinks(path) + require.NoError(t, err) + require.Truef(t, strings.HasPrefix(resolved, root+string(filepath.Separator)), "symlink %s escapes %s", path, root) + } + return nil + }) + require.NoError(t, err) + return stats +} + +// bundledBinaries lists the executables under /opt/kagent-tools/bin; the MCP +// server comes first, the CLIs it wraps follow and also get /usr/bin links. +var bundledBinaries = []string{"kagent-tools", "kubectl", "helm", "istioctl", "kubectl-argo-rollouts", "cilium"} + +func TestPluginImageLayout(t *testing.T) { + imageRoot := os.Getenv("KAGENT_TOOLS_IMAGE_ROOT") + if imageRoot == "" { + t.Skip("set KAGENT_TOOLS_IMAGE_ROOT to an exported image filesystem (scripts/verify-plugin-image.sh)") + } + + fullRoot := filepath.Join(imageRoot, "opt", "kagent-tools") + for _, binary := range bundledBinaries { + info, err := os.Stat(filepath.Join(fullRoot, "bin", binary)) + require.NoError(t, err, binary) + require.NotZero(t, info.Mode().Perm()&0o111, "%s must be executable", binary) + } + for _, file := range []string{"plugin.json", "mcp.json", "config/kubeconfig"} { + require.FileExists(t, filepath.Join(fullRoot, file)) + } + fullStats := walkPluginPackage(t, fullRoot) + require.Less(t, fullStats.Entries, int64(kagentMaxPackageEntries)) + t.Logf("full plugin root: %d entries, %d MiB (requires kagent mounted-package size exemption)", fullStats.Entries, fullStats.Bytes>>20) + + skillsRoot := filepath.Join(imageRoot, "opt", "kagent-tools-skills") + require.FileExists(t, filepath.Join(skillsRoot, "plugin.json")) + require.NoFileExists(t, filepath.Join(skillsRoot, "mcp.json")) + skillsStats := walkPluginPackage(t, skillsRoot) + require.Less(t, skillsStats.Entries, int64(kagentMaxPackageEntries)) + require.Less(t, skillsStats.Bytes, int64(kagentMaxPackageBytes)) + + fullSkills, err := os.ReadDir(filepath.Join(fullRoot, "skills")) + require.NoError(t, err) + onlySkills, err := os.ReadDir(filepath.Join(skillsRoot, "skills")) + require.NoError(t, err) + require.Equal(t, len(fullSkills), len(onlySkills)) + + legacyLinks := map[string]string{"tool-server": "/opt/kagent-tools/bin/kagent-tools"} + for _, binary := range bundledBinaries[1:] { + legacyLinks[filepath.Join("usr", "bin", binary)] = "/opt/kagent-tools/bin/" + binary + } + for link, expectedTarget := range legacyLinks { + target, err := os.Readlink(filepath.Join(imageRoot, link)) + require.NoError(t, err, link) + require.Equal(t, expectedTarget, target, link) + } +} diff --git a/cmd/server_config.go b/cmd/server_config.go new file mode 100644 index 00000000..aad3b866 --- /dev/null +++ b/cmd/server_config.go @@ -0,0 +1,93 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + + "github.com/kagent-dev/tools/internal/logger" +) + +const ( + envTools = "KAGENT_TOOLS_TOOLS" + envReadOnly = "KAGENT_TOOLS_READ_ONLY" + envDefaultKubeconfig = "KAGENT_TOOLS_DEFAULT_KUBECONFIG" + envKubeconfig = "KUBECONFIG" +) + +// serverFlags captures CLI flag values and whether they were set explicitly. +type serverFlags struct { + Tools []string + ToolsSet bool + ReadOnly bool + ReadOnlySet bool + Kubeconfig string +} + +// serverConfig is the effective configuration after flags and environment. +// Kubeconfig is empty when the CLIs should use KUBECONFIG or in-cluster config. +type serverConfig struct { + Tools []string + ReadOnly bool + Kubeconfig string +} + +// resolveServerConfig applies flag > environment > default precedence. Env +// fallbacks let kagent Harness env tune the server when it runs as a plugin +// stdio MCP server whose arguments are fixed in the image. +func resolveServerConfig(flags serverFlags, lookupEnv func(string) (string, bool)) (serverConfig, error) { + resolved := serverConfig{Tools: flags.Tools, ReadOnly: flags.ReadOnly, Kubeconfig: flags.Kubeconfig} + + if !flags.ToolsSet { + if value, ok := lookupEnv(envTools); ok { + resolved.Tools = splitToolList(value) + } + } + if !flags.ReadOnlySet { + if value, ok := lookupEnv(envReadOnly); ok && value != "" { + parsed, err := strconv.ParseBool(value) + if err != nil { + return serverConfig{}, fmt.Errorf("invalid %s %q: %w", envReadOnly, value, err) + } + resolved.ReadOnly = parsed + } + } + if resolved.Kubeconfig == "" { + if value, ok := lookupEnv(envKubeconfig); ok && value != "" { + return resolved, nil + } + if value, ok := lookupEnv(envDefaultKubeconfig); ok { + resolved.Kubeconfig = value + } + } + return resolved, nil +} + +// applyServerEnvironment exports process environment derived from the +// effective configuration. Only k8s and kubescape receive the kubeconfig path +// explicitly; exporting KUBECONFIG makes helm, istioctl, cilium and argo target +// the same cluster. In stdio mode stdout carries the MCP protocol, so console +// output is routed to stderr. +func applyServerEnvironment(config serverConfig, stdioMode bool, setEnv func(key, value string) error) error { + if config.Kubeconfig != "" { + if err := setEnv(envKubeconfig, config.Kubeconfig); err != nil { + return fmt.Errorf("export %s: %w", envKubeconfig, err) + } + } + if stdioMode { + if err := setEnv(logger.UseStderrEnv, "true"); err != nil { + return fmt.Errorf("export %s: %w", logger.UseStderrEnv, err) + } + } + return nil +} + +func splitToolList(value string) []string { + var toolNames []string + for _, part := range strings.Split(value, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + toolNames = append(toolNames, trimmed) + } + } + return toolNames +} diff --git a/cmd/server_config_test.go b/cmd/server_config_test.go new file mode 100644 index 00000000..c39056a0 --- /dev/null +++ b/cmd/server_config_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "errors" + "testing" + + "github.com/kagent-dev/tools/internal/logger" + "github.com/stretchr/testify/require" +) + +func envFrom(values map[string]string) func(string) (string, bool) { + return func(key string) (string, bool) { + value, ok := values[key] + return value, ok + } +} + +func TestResolveServerConfig(t *testing.T) { + testCases := []struct { + name string + flags serverFlags + env map[string]string + expected serverConfig + wantErr string + }{ + {name: "defaults", expected: serverConfig{}}, + { + name: "flags win over env", + flags: serverFlags{Tools: []string{"k8s"}, ToolsSet: true, ReadOnly: false, ReadOnlySet: true, Kubeconfig: "/flag"}, + env: map[string]string{envTools: "helm", envReadOnly: "true", envKubeconfig: "/env", envDefaultKubeconfig: "/default"}, + expected: serverConfig{Tools: []string{"k8s"}, ReadOnly: false, Kubeconfig: "/flag"}, + }, + { + name: "env fallbacks when flags unset", + env: map[string]string{envTools: " k8s, helm ,,", envReadOnly: "true"}, + expected: serverConfig{Tools: []string{"k8s", "helm"}, ReadOnly: true}, + }, + { + name: "KUBECONFIG env is left to the CLIs", + env: map[string]string{envKubeconfig: "/a:/b", envDefaultKubeconfig: "/default"}, + expected: serverConfig{}, + }, + { + name: "plugin default kubeconfig used last", + env: map[string]string{envDefaultKubeconfig: "/plugin/config/kubeconfig"}, + expected: serverConfig{Kubeconfig: "/plugin/config/kubeconfig"}, + }, + { + name: "empty KUBECONFIG does not block default", + env: map[string]string{envKubeconfig: "", envDefaultKubeconfig: "/d"}, + expected: serverConfig{Kubeconfig: "/d"}, + }, + { + name: "invalid read-only env", + env: map[string]string{envReadOnly: "maybe"}, + wantErr: envReadOnly, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + resolved, err := resolveServerConfig(testCase.flags, envFrom(testCase.env)) + if testCase.wantErr != "" { + require.ErrorContains(t, err, testCase.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, testCase.expected, resolved) + }) + } +} + +func TestApplyServerEnvironment(t *testing.T) { + testCases := []struct { + name string + config serverConfig + stdioMode bool + expected map[string]string + }{ + {name: "nothing to export", expected: map[string]string{}}, + { + name: "kubeconfig exported for every CLI", + config: serverConfig{Kubeconfig: "/plugin/config/kubeconfig"}, + expected: map[string]string{envKubeconfig: "/plugin/config/kubeconfig"}, + }, + { + name: "stdio routes console output to stderr", + stdioMode: true, + expected: map[string]string{logger.UseStderrEnv: "true"}, + }, + { + name: "stdio plugin mode exports both", + config: serverConfig{Kubeconfig: "/k"}, + stdioMode: true, + expected: map[string]string{envKubeconfig: "/k", logger.UseStderrEnv: "true"}, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + exported := map[string]string{} + err := applyServerEnvironment(testCase.config, testCase.stdioMode, func(key, value string) error { + exported[key] = value + return nil + }) + require.NoError(t, err) + require.Equal(t, testCase.expected, exported) + }) + } +} + +func TestApplyServerEnvironmentPropagatesErrors(t *testing.T) { + failingSetEnv := func(string, string) error { return errors.New("denied") } + + err := applyServerEnvironment(serverConfig{Kubeconfig: "/k"}, false, failingSetEnv) + require.ErrorContains(t, err, envKubeconfig) + + err = applyServerEnvironment(serverConfig{}, true, failingSetEnv) + require.ErrorContains(t, err, logger.UseStderrEnv) +} diff --git a/docs/kagent-skills.md b/docs/kagent-skills.md new file mode 100644 index 00000000..9575aa8d --- /dev/null +++ b/docs/kagent-skills.md @@ -0,0 +1,221 @@ +# Using kagent-tools as a kagent 1.x skills bundle + +The kagent-tools image is also a kagent 1.x **Agent Plugin**: an agent can mount it read-only +as an OCI image volume and get the MCP tools server, all bundled CLIs, and a set of operational +skills, with no separate deployment. + +## What you get + +The image contains two plugin roots. Select **one** of them per agent. + +| Root (`source.path`) | Contents | +|---|---| +| `opt/kagent-tools` | `plugin.json`, `mcp.json` (stdio MCP server), `bin/` (`kagent-tools`, `kubectl`, `helm`, `istioctl`, `kubectl-argo-rollouts`, `cilium`), `config/kubeconfig`, `skills/` | +| `opt/kagent-tools-skills` | `plugin.json` and `skills/` only (use with a `RemoteMCPServer`) | + +Skills (identical in both roots): + +| Skill | Use it for | +|---|---| +| `k8s-troubleshooting` | Unhealthy workloads: CrashLoopBackOff, Pending, ImagePullBackOff, OOMKilled, failing readiness, unreachable Services | +| `helm-release-management` | Installed charts, release status, values, failed upgrades, chart repositories | +| `istio-traffic-debugging` | Mesh 503s, mTLS failures, routing not applied, sidecar or ambient/waypoint issues | +| `argo-rollouts-progressive-delivery` | Canary and blue-green Rollouts: status, promote, pause, new image | +| `cilium-network-debugging` | Cilium agent health, endpoints and identities, policy drops, services, BGP, cluster mesh | +| `kubescape-security-posture` | Configuration scans, image CVEs, application profiles, network neighborhoods | +| `prometheus-observability` | Error rates, latency, saturation, scrape health (needs `PROMETHEUS_URL`) | + +Skills are playbooks that call the MCP tools by name (`k8s_get_resources`, `helm_list_releases`, +...). They work the same whether the tools come from the bundled stdio server or a +`RemoteMCPServer`. + +## Requirements + +- kagent 1.x (`api.kagent.dev/v1alpha3`) with the **Go ADK (`kagent`) harness**. Only that + harness starts plugin `mcp.json` servers; Codex and Claude harnesses get the skills only. +- For the full root (`opt/kagent-tools`): a kagent release that does not apply the 100 MiB + package-size limit to platform-mounted OCI packages. The full root is ~550–650 MiB because it + contains the CLIs. Older kagent releases must use [skills-only mode](#skills-only-mode). +- The image pinned by digest. Print it with: + + ```bash + docker buildx imagetools inspect ghcr.io/kagent-dev/kagent/tools: + ``` + +## Cluster access + +Inside the agent, the bundled server uses `config/kubeconfig`. It points at +`https://kubernetes.default.svc`, trusts the kagent egress CA +(`/run/kagent/egress/trust-bundle.pem`) and carries the placeholder token +`kagent-credential-injected`. The kagent egress gateway replaces that token with a real +ServiceAccount token from an `EgressPolicy`, so no credential ever enters the agent sandbox. +RBAC on the ServiceAccount is the authorization boundary. + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kagent-tools-agent + namespace: kagent +--- +apiVersion: v1 +kind: Secret +metadata: + name: kagent-tools-agent-token + namespace: kagent + annotations: + kubernetes.io/service-account.name: kagent-tools-agent +type: kubernetes.io/service-account-token +--- +# "view" for read-only agents; bind a custom role if the agent may use write tools. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: kagent-tools-agent-view +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: view +subjects: + - kind: ServiceAccount + name: kagent-tools-agent + namespace: kagent +--- +apiVersion: api.kagent.dev/v1alpha3 +kind: EgressPolicy +metadata: + name: kube-api + namespace: kagent +spec: + destinations: + - origin: https://kubernetes.default.svc + authentication: + header: Authorization + prefix: "Bearer " + secretKeyRef: + name: kagent-tools-agent-token + key: token +``` + +## Agent + +```yaml +apiVersion: api.kagent.dev/v1alpha3 +kind: AgentTemplate +metadata: + name: k8s-operator + namespace: kagent +spec: + systemPrompt: Use the kagent-tools skills and tools to operate the cluster. + plugins: + - source: + oci: ghcr.io/kagent-dev/kagent/tools@sha256: + path: opt/kagent-tools + skills: + - k8s-troubleshooting + - helm-release-management +--- +apiVersion: api.kagent.dev/v1alpha3 +kind: Agent +metadata: + name: k8s-operator + namespace: kagent +spec: + template: + ref: + name: k8s-operator + harnessRef: + name: kagent # a Harness with spec.kagent (Go ADK) + egressPolicyRefs: + - name: kube-api +``` + +kagent starts `bin/kagent-tools --stdio` from the plugin root and exposes all its tools to the +model. The selected skills are copied to `/skills/` and offered through `list_skills` / +`load_skill`. + +## Tuning via Harness env + +The server reads these variables when the matching flag is not set. Set them in the Harness +`spec.env`; `mcp.json` deliberately leaves them unset so the Harness value applies. + +| Variable | Effect | +|---|---| +| `KAGENT_TOOLS_READ_ONLY=true` | Do not register write tools (same as `--read-only`) | +| `KAGENT_TOOLS_TOOLS=k8s,helm` | Register only these providers (same as `--tools`) | +| `KUBECONFIG=` | Use a different kubeconfig instead of the bundled egress kubeconfig | +| `PROMETHEUS_URL=https://prometheus.example.com` | Prometheus endpoint; also add its origin to an EgressPolicy | +| `OTEL_EXPORTER_OTLP_ENDPOINT=...` | Export traces; without it, traces go to stderr in stdio mode | + +```yaml +spec: + env: + - name: KAGENT_TOOLS_READ_ONLY + value: "true" + - name: KAGENT_TOOLS_TOOLS + value: k8s,helm +``` + +Kubeconfig precedence is `--kubeconfig` > `KUBECONFIG` > `KAGENT_TOOLS_DEFAULT_KUBECONFIG` (set +by `mcp.json` to the bundled kubeconfig) > in-cluster config. The resolved path is exported as +`KUBECONFIG` so every CLI (helm, istioctl, cilium, argo) targets the same cluster. + +## Skills-only mode + +Use this on kagent releases that still enforce the 100 MiB limit on mounted packages, or with +harnesses that do not start plugin MCP servers. Deploy the server with the Helm chart as usual, +then reference it as a `RemoteMCPServer` and select the skills-only root: + +```yaml +apiVersion: api.kagent.dev/v1alpha3 +kind: RemoteMCPServer +metadata: + name: kagent-tools + namespace: kagent +spec: + description: kagent-tools MCP server + url: http://kagent-tools.kagent:8084/mcp +--- +apiVersion: api.kagent.dev/v1alpha3 +kind: AgentTemplate +metadata: + name: k8s-operator + namespace: kagent +spec: + systemPrompt: Use the kagent-tools skills and tools to operate the cluster. + tools: + - mcp: + server: + kind: RemoteMCPServer + name: kagent-tools + plugins: + - source: + oci: ghcr.io/kagent-dev/kagent/tools@sha256: + path: opt/kagent-tools-skills + skills: + - k8s-troubleshooting +``` + +> **Never select both roots in one agent.** They contain the same skill names, and kagent +> rejects duplicate skills. + +## Troubleshooting + +| Symptom | Cause and fix | +|---|---| +| Agent fails with `artifact exceeds 104857600 bytes` | The kagent release applies the size limit to mounted packages. Use skills-only mode. | +| Tools return `401 Unauthorized` / `403 Forbidden` | The EgressPolicy Secret is wrong or empty, or RBAC on `kagent-tools-agent` does not allow the request. | +| Tools return connection refused or a blocked-egress error | The Agent does not list the EgressPolicy in `egressPolicyRefs`, or the origin does not match `https://kubernetes.default.svc`. | +| `unable to read certificate-authority /run/kagent/egress/trust-bundle.pem` | The agent is not running behind the kagent egress gateway (no trust bundle mounted). Set `KUBECONFIG` on the Harness or use skills-only mode. | +| Skills appear but no `k8s_*` tools | The harness is not the Go ADK `kagent` harness. Use skills-only mode. | +| `exec`, `logs -f`, `port-forward` hang or fail | These need HTTP upgrade/streaming through the egress gateway and are best effort. Prefer non-streaming calls. | + +## Verifying an image locally + +```bash +make plugin-verify +``` + +This builds the image, exports its filesystem, checks both roots against kagent's package rules +(file types, symlinks, entry count, skills-only size), and runs an MCP `initialize` + +`tools/list` over stdio to confirm stdout carries only JSON-RPC. diff --git a/docs/superpowers/plans/2026-10-24-kagent-tools-skills-bundle.md b/docs/superpowers/plans/2026-10-24-kagent-tools-skills-bundle.md new file mode 100644 index 00000000..8bf54f7a --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-kagent-tools-skills-bundle.md @@ -0,0 +1,1272 @@ +# kagent-tools OCI Skills Bundle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the `kagent-tools` image usable unchanged both as the existing MCP server and as a kagent 1.x Agent Plugin (OCI skills bundle) that carries all CLIs, the MCP server (stdio), and skills. + +**Architecture:** Restructure the image so everything lives under the Agent Plugin root `/opt/kagent-tools` (`plugin.json`, `mcp.json`, `bin/*`, `config/kubeconfig`, `skills/*`), plus a skills-only root `/opt/kagent-tools-skills`. Legacy paths (`/tool-server`, `/bin/*`) become symlinks outside both roots. kagent mounts the image read-only, copies selected skills, and starts `./bin/kagent-tools --stdio` from `mcp.json`. The server gains env fallbacks for flags, a kubeconfig resolution chain, and a stdio-safe trace exporter. + +**Tech Stack:** Go 1.27, cobra, MCP Go SDK, testify, Docker buildx (distroless), kagent `api.kagent.dev/v1alpha3`. + +**Spec:** `docs/superpowers/specs/2026-10-24-kagent-tools-skills-bundle-design.md` + +--- + +## File map + +| File | Action | Responsibility | +|---|---|---| +| `cmd/server_config.go` | create | `resolveServerConfig`: flag/env precedence, kubeconfig chain | +| `cmd/server_config_test.go` | create | precedence table tests | +| `cmd/main.go` | modify | wire `resolveServerConfig`, export `KUBECONFIG`, force stderr in stdio | +| `internal/telemetry/tracing.go` | modify | console exporter → stderr when `KAGENT_USE_STDERR=true` | +| `internal/telemetry/console_writer_test.go` | create | test for writer choice | +| `plugin/plugin.json.tmpl` | create | full plugin manifest (`__VERSION__` placeholder) | +| `plugin/skills-plugin.json.tmpl` | create | skills-only manifest | +| `plugin/mcp.json` | create | stdio server declaration | +| `plugin/config/kubeconfig` | create | egress-gateway kubeconfig | +| `plugin/skills/<7 skills>/SKILL.md` | create | playbooks | +| `cmd/plugin_bundle_test.go` | create | validates `plugin/` source tree + skill tool names | +| `cmd/plugin_image_test.go` | create | validates an exported image tree (opt-in via env) | +| `scripts/verify-plugin-image.sh` | create | export image, run image test, stdio smoke | +| `Dockerfile` | modify | assemble plugin roots, symlinks, entrypoint | +| `Makefile` | modify | `plugin-verify` target | +| `.github/workflows/ci.yaml` | modify | run `make plugin-verify` | +| `docs/kagent-skills.md` | create | user guide (RBAC, EgressPolicy, AgentTemplate) | +| `README.md`, `AGENTS.md` | modify | links + `plugin/` in repo structure | + +Task 0 is in `kagent-dev/kagent` and can proceed in parallel. Tasks 1–9 are in this repo. + +--- + +### Task 0 (upstream, `kagent-dev/kagent`): Do not byte-limit platform-mounted packages + +**Files:** +- Modify: `go/core/pkg/agentplugins/materialize.go` (`fetchSource`, `validatedPackageRoot`, `validatePackage`, `copySkill`) +- Test: `go/core/pkg/agentplugins/materialize_test.go` + +- [ ] **Step 1: Write the failing test** (append to `materialize_test.go`) + +```go +func TestMountedPluginMayExceedPackageByteLimit(t *testing.T) { + mounted := t.TempDir() + root := filepath.Join(mounted, "opt", "bundle") + require.NoError(t, os.MkdirAll(filepath.Join(root, "bin"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "skills", "small"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "plugin.json"), + []byte(`{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"bundle"}`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "skills", "small", "SKILL.md"), + []byte("---\nname: small\ndescription: d\n---\nbody\n"), 0o644)) + big, err := os.Create(filepath.Join(root, "bin", "big")) + require.NoError(t, err) + require.NoError(t, big.Truncate(maxPackageBytes+1)) // sparse; counted by size + require.NoError(t, big.Close()) + + out := t.TempDir() + _, err = Materialize(context.Background(), agentplugin.Resources{ + Plugins: []agentplugin.Bundle{{ + Source: agentplugin.Source{Directory: mounted, Path: "opt/bundle"}, + Skills: []string{"small"}, + }}, + }, Paths{Packages: filepath.Join(out, "packages"), Skills: filepath.Join(out, "skills")}) + require.NoError(t, err) + require.FileExists(t, filepath.Join(out, "skills", "small", "SKILL.md")) +} + +func TestCopiedSkillStillByteLimited(t *testing.T) { + src := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("---\nname: s\ndescription: d\n---\n"), 0o644)) + big, err := os.Create(filepath.Join(src, "big")) + require.NoError(t, err) + require.NoError(t, big.Truncate(maxPackageBytes+1)) + require.NoError(t, big.Close()) + err = copySkill(src, filepath.Join(t.TempDir(), "s")) + require.ErrorContains(t, err, "exceeds") +} +``` + +(Match the `agentplugin.Bundle` field names to `go/api/agentplugin/types.go`. If the bundle type is named differently, use that name.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd go && go test ./core/pkg/agentplugins/ -run 'TestMountedPluginMayExceed|TestCopiedSkillStillByteLimited' -v` +Expected: FAIL, the first with `artifact exceeds 104857600 bytes` and the second with no error. + +- [ ] **Step 3: Implement** + +In `materialize.go`: + +```go +// validatePackage checks entry count, file types, and symlink containment. +// limitBytes is false for read-only platform mounts, which are never copied +// or downloaded as a whole; copySkill enforces its own byte budget. +func validatePackage(root string, limitBytes bool) error { + var entries, bytes int64 + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + // … unchanged … + if info.Mode().IsRegular() { + bytes += info.Size() + if limitBytes && bytes > maxPackageBytes { + return fmt.Errorf("artifact exceeds %d bytes", maxPackageBytes) + } + } + // … unchanged … + }) +} + +func validatedPackageRoot(root, relative string, limitBytes bool) (string, error) { + selected, err := containedPath(root, relative) + if err != nil { + return "", err + } + if err := validatePackage(selected, limitBytes); err != nil { + return "", err + } + return selected, nil +} +``` + +In `fetchSource`: the `source.Directory` branch calls `validatedPackageRoot(source.Directory, source.Path, false)`. Every Git/S3 call site passes `true`. + +In `copySkill`, call `validatePackage(source, true)` before walking, so a copied skill stays bounded to 100 MiB. + +- [ ] **Step 4: Run the package tests** + +Run: `cd go && go test ./core/pkg/agentplugins/... -v` +Expected: PASS (new and existing). + +- [ ] **Step 5: Update docs and open the PR** + +In `docs/architecture/configuration-and-compilation.md` § "OCI skill delivery", replace "Package limits and path/symlink validation still apply." with: +"Path, symlink, file-type, and entry-count validation apply to every package. The 100 MiB byte limit applies to Git/S3 downloads and to each skill copied into the harness discovery directory, not to read-only mounted images." + +```bash +git checkout -b feat/mounted-plugin-size +git add go/core/pkg/agentplugins docs/architecture/configuration-and-compilation.md +git commit -s -m "feat(agentplugins): do not byte-limit platform-mounted plugin packages" +``` + +--- + +### Task 1: Server config resolution (flags > env > defaults, kubeconfig chain) + +**Files:** +- Create: `cmd/server_config.go` +- Test: `cmd/server_config_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func envFrom(values map[string]string) func(string) (string, bool) { + return func(key string) (string, bool) { + value, ok := values[key] + return value, ok + } +} + +func TestResolveServerConfig(t *testing.T) { + testCases := []struct { + name string + flags serverFlags + env map[string]string + expected serverConfig + wantErr string + }{ + {name: "defaults", expected: serverConfig{}}, + { + name: "flags win over env", + flags: serverFlags{Tools: []string{"k8s"}, ToolsSet: true, ReadOnly: false, ReadOnlySet: true, Kubeconfig: "/flag"}, + env: map[string]string{envTools: "helm", envReadOnly: "true", "KUBECONFIG": "/env", envDefaultKubeconfig: "/default"}, + expected: serverConfig{Tools: []string{"k8s"}, ReadOnly: false, Kubeconfig: "/flag"}, + }, + { + name: "env fallbacks when flags unset", + env: map[string]string{envTools: " k8s, helm ,,", envReadOnly: "true"}, + expected: serverConfig{Tools: []string{"k8s", "helm"}, ReadOnly: true}, + }, + { + name: "KUBECONFIG env is left to the CLIs", + env: map[string]string{"KUBECONFIG": "/a:/b", envDefaultKubeconfig: "/default"}, + expected: serverConfig{}, + }, + { + name: "plugin default kubeconfig used last", + env: map[string]string{envDefaultKubeconfig: "/plugin/config/kubeconfig"}, + expected: serverConfig{Kubeconfig: "/plugin/config/kubeconfig"}, + }, + { + name: "empty KUBECONFIG does not block default", + env: map[string]string{"KUBECONFIG": "", envDefaultKubeconfig: "/d"}, + expected: serverConfig{Kubeconfig: "/d"}, + }, + { + name: "invalid read-only env", + env: map[string]string{envReadOnly: "maybe"}, + wantErr: envReadOnly, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + resolved, err := resolveServerConfig(testCase.flags, envFrom(testCase.env)) + if testCase.wantErr != "" { + require.ErrorContains(t, err, testCase.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, testCase.expected, resolved) + }) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./cmd -run TestResolveServerConfig -v` +Expected: FAIL to compile (`undefined: serverFlags`). + +- [ ] **Step 3: Implement `cmd/server_config.go`** + +```go +package main + +import ( + "fmt" + "strconv" + "strings" +) + +const ( + envTools = "KAGENT_TOOLS_TOOLS" + envReadOnly = "KAGENT_TOOLS_READ_ONLY" + envDefaultKubeconfig = "KAGENT_TOOLS_DEFAULT_KUBECONFIG" +) + +// serverFlags captures CLI flag values and whether they were set explicitly. +type serverFlags struct { + Tools []string + ToolsSet bool + ReadOnly bool + ReadOnlySet bool + Kubeconfig string +} + +// serverConfig is the effective configuration after flags and environment. +// Kubeconfig is empty when the CLIs should use KUBECONFIG or in-cluster config. +type serverConfig struct { + Tools []string + ReadOnly bool + Kubeconfig string +} + +// resolveServerConfig applies flag > environment > default precedence. Env +// fallbacks let kagent Harness env tune the server when it runs as a plugin +// stdio MCP server whose arguments are fixed in the image. +func resolveServerConfig(flags serverFlags, lookupEnv func(string) (string, bool)) (serverConfig, error) { + resolved := serverConfig{Tools: flags.Tools, ReadOnly: flags.ReadOnly, Kubeconfig: flags.Kubeconfig} + + if !flags.ToolsSet { + if value, ok := lookupEnv(envTools); ok { + resolved.Tools = splitToolList(value) + } + } + if !flags.ReadOnlySet { + if value, ok := lookupEnv(envReadOnly); ok && value != "" { + parsed, err := strconv.ParseBool(value) + if err != nil { + return serverConfig{}, fmt.Errorf("invalid %s %q: %w", envReadOnly, value, err) + } + resolved.ReadOnly = parsed + } + } + if resolved.Kubeconfig == "" { + if value, ok := lookupEnv("KUBECONFIG"); ok && value != "" { + return resolved, nil + } + if value, ok := lookupEnv(envDefaultKubeconfig); ok { + resolved.Kubeconfig = value + } + } + return resolved, nil +} + +func splitToolList(value string) []string { + var toolNames []string + for _, part := range strings.Split(value, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + toolNames = append(toolNames, trimmed) + } + } + return toolNames +} +``` + +Note: the "defaults" case expects `Tools: nil`. `flags.Tools` is nil in that case, so `require.Equal` holds. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./cmd -run TestResolveServerConfig -v` +Expected: PASS (7 subtests). + +- [ ] **Step 5: Commit** + +```bash +git add cmd/server_config.go cmd/server_config_test.go +git commit -s -m "feat: resolve tools, read-only and kubeconfig from env fallbacks" +``` + +--- + +### Task 2: Wire config into `run()` and make stdio output protocol-safe + +**Files:** +- Modify: `cmd/main.go` (`run`, around lines 114–158) +- Modify: `internal/telemetry/tracing.go:146-148` +- Test: `internal/telemetry/console_writer_test.go` + +- [ ] **Step 1: Write the failing telemetry test** + +```go +package telemetry + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConsoleWriterUsesStderrWhenRequested(t *testing.T) { + t.Setenv("KAGENT_USE_STDERR", "true") + require.Same(t, os.Stderr, consoleWriter()) +} + +func TestConsoleWriterDefaultsToStdout(t *testing.T) { + t.Setenv("KAGENT_USE_STDERR", "") + require.Same(t, os.Stdout, consoleWriter()) +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `go test ./internal/telemetry -run TestConsoleWriter -v` +Expected: FAIL (`undefined: consoleWriter`). + +- [ ] **Step 3: Implement in `internal/telemetry/tracing.go`** + +Add `"os"` to this file's imports if it is missing, then: + +```go +// consoleWriter keeps the fallback span exporter off stdout when stdout +// carries the MCP stdio protocol. +func consoleWriter() *os.File { + if os.Getenv("KAGENT_USE_STDERR") == "true" { + return os.Stderr + } + return os.Stdout +} +``` + +Replace the fallback in `createExporter`: + +```go + if cfg.Endpoint == "" { + return stdouttrace.New(stdouttrace.WithWriter(consoleWriter()), stdouttrace.WithPrettyPrint()) + } +``` + +- [ ] **Step 4: Wire `cmd/main.go`** + +In `run()`, directly after the `if metricsPort == 0 { … }` block and **before** `logger.Init(stdio)`, insert: + +```go + effectiveConfig, err := resolveServerConfig(serverFlags{ + Tools: tools, + ToolsSet: cmd.Flags().Changed("tools"), + ReadOnly: readOnly, + ReadOnlySet: cmd.Flags().Changed("read-only"), + Kubeconfig: *kubeconfig, + }, os.LookupEnv) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + tools, readOnly = effectiveConfig.Tools, effectiveConfig.ReadOnly + if effectiveConfig.Kubeconfig != "" { + // Only k8s and kubescape receive the path explicitly; exporting it makes + // helm, istioctl, cilium and argo target the same cluster. + _ = os.Setenv("KUBECONFIG", effectiveConfig.Kubeconfig) + } + if stdio { + _ = os.Setenv("KAGENT_USE_STDERR", "true") + } +``` + +Then change the later `err := telemetry.SetupOTelSDK(ctx)` to `err = telemetry.SetupOTelSDK(ctx)`, because `err` is now declared. Change `registerMCP(mcpSrv, tools, *kubeconfig, readOnly)` to `registerMCP(mcpSrv, tools, effectiveConfig.Kubeconfig, readOnly)`. + +- [ ] **Step 5: Run the tests and a manual stdio check** + +Run: `go test ./cmd ./internal/telemetry -v 2>&1 | tail -20` +Expected: PASS. + +Run: +```bash +go build -o /tmp/kt ./cmd && printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ + | KAGENT_TOOLS_TOOLS=utils /tmp/kt --stdio 2>/dev/null | head -c 400; echo +``` +Expected: stdout contains only JSON-RPC lines (`"id":1` and `"id":2` with `datetime_get_current_time`) and no pretty-printed span JSON. + +- [ ] **Step 6: Commit** + +```bash +git add cmd/main.go internal/telemetry/tracing.go internal/telemetry/console_writer_test.go +git commit -s -m "fix: keep stdio MCP stream clean and apply env config fallbacks" +``` + +--- + +### Task 3: Plugin source tree (manifests, mcp.json, kubeconfig) + +**Files:** create everything under `plugin/` listed below. + +- [ ] **Step 1: Create `plugin/plugin.json.tmpl`** + +```json +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "kagent-tools", + "version": "__VERSION__", + "description": "Kubernetes, Helm, Istio, Argo Rollouts, Cilium, Kubescape and Prometheus tools with operational skills for kagent agents.", + "homepage": "https://github.com/kagent-dev/tools", + "repository": "https://github.com/kagent-dev/tools", + "license": "Apache-2.0", + "keywords": ["kubernetes", "helm", "istio", "argo-rollouts", "cilium", "kubescape", "prometheus", "mcp"] +} +``` + +- [ ] **Step 2: Create `plugin/skills-plugin.json.tmpl`** + +```json +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "kagent-tools-skills", + "version": "__VERSION__", + "description": "Operational skills for the kagent-tools MCP server (use with a kagent-tools RemoteMCPServer).", + "homepage": "https://github.com/kagent-dev/tools", + "repository": "https://github.com/kagent-dev/tools", + "license": "Apache-2.0" +} +``` + +- [ ] **Step 3: Create `plugin/mcp.json`** + +```json +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "kagent-tools": { + "type": "stdio", + "command": "./bin/kagent-tools", + "args": ["--stdio"], + "env": { + "PATH": "${PLUGIN_ROOT}/bin:/usr/local/bin:/usr/bin:/bin", + "KAGENT_TOOLS_DEFAULT_KUBECONFIG": "${PLUGIN_ROOT}/config/kubeconfig", + "KAGENT_USE_STDERR": "true", + "HOME": "${PLUGIN_DATA}", + "XDG_CACHE_HOME": "${PLUGIN_DATA}/cache", + "XDG_CONFIG_HOME": "${PLUGIN_DATA}/config", + "XDG_DATA_HOME": "${PLUGIN_DATA}/data" + } + } + } +} +``` + +- [ ] **Step 4: Create `plugin/config/kubeconfig`** + +```yaml +# Used when kagent-tools runs as a kagent Agent Plugin. Requests go through the +# kagent/Substrate egress gateway, which replaces the placeholder bearer token +# with a ServiceAccount token from an EgressPolicy. No credentials live here. +apiVersion: v1 +kind: Config +clusters: + - name: in-cluster + cluster: + server: https://kubernetes.default.svc + certificate-authority: /run/kagent/egress/trust-bundle.pem +users: + - name: kagent-egress + user: + token: kagent-credential-injected +contexts: + - name: default + context: + cluster: in-cluster + user: kagent-egress +current-context: default +``` + +- [ ] **Step 5: Commit** + +```bash +git add plugin/plugin.json.tmpl plugin/skills-plugin.json.tmpl plugin/mcp.json plugin/config/kubeconfig +git commit -s -m "feat(plugin): add kagent Agent Plugin manifests and egress kubeconfig" +``` + +--- + +### Task 4: Plugin source validation test + +**Files:** +- Create: `cmd/plugin_bundle_test.go` +- Modify: `go.mod` (promote `sigs.k8s.io/yaml` to a direct dependency through `go mod tidy`) + +- [ ] **Step 1: Write the test** (it fails now because `plugin/skills` does not exist) + +```go +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" +) + +const pluginSourceDir = "../plugin" + +// Mirrors kagent go/core/pkg/agentplugins manifest and mcpServer types. +type pluginManifest struct { + Schema string `json:"$schema"` + Name string `json:"name"` + Version string `json:"version,omitempty"` + Description string `json:"description,omitempty"` + Homepage string `json:"homepage,omitempty"` + Repository string `json:"repository,omitempty"` + License string `json:"license,omitempty"` + Keywords []string `json:"keywords,omitempty"` +} + +type mcpDocument struct { + Schema string `json:"$schema"` + Servers map[string]json.RawMessage `json:"mcpServers"` +} + +type mcpStdioServer struct { + Type string `json:"type"` + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` + CWD string `json:"cwd,omitempty"` +} + +type skillFrontmatter struct { + Name string `json:"name"` + Description string `json:"description"` +} + +var ( + pluginNamePattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$`) + skillNamePattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + toolReference = regexp.MustCompile("`((?:k8s|helm|istio|argo|cilium|kubescape|prometheus|datetime)_[a-z_]+)`") +) + +func decodeStrict(t *testing.T, raw []byte, target any) { + t.Helper() + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + require.NoError(t, decoder.Decode(target)) +} + +func TestPluginManifestsAreValid(t *testing.T) { + for _, file := range []string{"plugin.json.tmpl", "skills-plugin.json.tmpl"} { + raw, err := os.ReadFile(filepath.Join(pluginSourceDir, file)) + require.NoError(t, err) + var manifest pluginManifest + decodeStrict(t, raw, &manifest) + require.Equal(t, "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", manifest.Schema, file) + require.Regexp(t, pluginNamePattern, manifest.Name, file) + require.LessOrEqual(t, len(manifest.Name), 64, file) + require.Equal(t, "__VERSION__", manifest.Version, file) + } +} + +func TestPluginMCPConfigIsValid(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(pluginSourceDir, "mcp.json")) + require.NoError(t, err) + var document mcpDocument + decodeStrict(t, raw, &document) + require.Equal(t, "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", document.Schema) + require.Contains(t, document.Servers, "kagent-tools") + + var server mcpStdioServer + decodeStrict(t, document.Servers["kagent-tools"], &server) + require.Equal(t, "stdio", server.Type) + require.Equal(t, "./bin/kagent-tools", server.Command) + require.Contains(t, server.Args, "--stdio") + require.NotContains(t, server.Env, "PLUGIN_ROOT") + require.NotContains(t, server.Env, "PLUGIN_DATA") + require.NotContains(t, server.Env, envTools, "operators set this on the Harness; mcp.json env would override it") + require.NotContains(t, server.Env, envReadOnly, "operators set this on the Harness; mcp.json env would override it") + require.NotContains(t, server.Env, "KUBECONFIG", "use KAGENT_TOOLS_DEFAULT_KUBECONFIG so Harness KUBECONFIG still wins") + require.Equal(t, "${PLUGIN_ROOT}/config/kubeconfig", server.Env[envDefaultKubeconfig]) + require.FileExists(t, filepath.Join(pluginSourceDir, "config", "kubeconfig")) +} + +func registeredToolNames(t *testing.T) map[string]bool { + t.Helper() + ctx := context.Background() + server := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "plugin", Version: "test"}, nil) + registerMCP(server, nil, "", false) + serverTransport, clientTransport := sdkmcp.NewInMemoryTransports() + go func() { _ = server.Run(ctx, serverTransport) }() + client := sdkmcp.NewClient(&sdkmcp.Implementation{Name: "plugin-client", Version: "test"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + names := map[string]bool{} + for tool, err := range session.Tools(ctx, nil) { + require.NoError(t, err) + names[tool.Name] = true + } + return names +} + +func TestPluginSkillsAreValid(t *testing.T) { + skillDirs, err := os.ReadDir(filepath.Join(pluginSourceDir, "skills")) + require.NoError(t, err) + require.NotEmpty(t, skillDirs) + knownTools := registeredToolNames(t) + + for _, skillDir := range skillDirs { + require.True(t, skillDir.IsDir(), skillDir.Name()) + raw, err := os.ReadFile(filepath.Join(pluginSourceDir, "skills", skillDir.Name(), "SKILL.md")) + require.NoError(t, err, skillDir.Name()) + + content := string(raw) + require.True(t, strings.HasPrefix(content, "---\n"), "%s: missing frontmatter", skillDir.Name()) + frontmatterEnd := strings.Index(content[4:], "\n---\n") + require.Positive(t, frontmatterEnd, "%s: unterminated frontmatter", skillDir.Name()) + var frontmatter skillFrontmatter + require.NoError(t, yaml.Unmarshal([]byte(content[4:4+frontmatterEnd]), &frontmatter)) + + require.Equal(t, skillDir.Name(), frontmatter.Name) + require.Regexp(t, skillNamePattern, frontmatter.Name) + require.LessOrEqual(t, len(frontmatter.Name), 64) + require.NotEmpty(t, frontmatter.Description) + require.LessOrEqual(t, len(frontmatter.Description), 1024) + + references := toolReference.FindAllStringSubmatch(content, -1) + require.NotEmpty(t, references, "%s: skill must reference at least one MCP tool", skillDir.Name()) + for _, reference := range references { + require.Truef(t, knownTools[reference[1]], "%s references unknown tool %q", skillDir.Name(), reference[1]) + } + } +} +``` + +- [ ] **Step 2: Run the tests** + +Run: `go mod tidy && go test ./cmd -run 'TestPlugin' -v` +Expected: `TestPluginManifestsAreValid` and `TestPluginMCPConfigIsValid` PASS; `TestPluginSkillsAreValid` FAILS (`no such file or directory … plugin/skills`). + +- [ ] **Step 3: Commit the test** (Task 5 makes it pass) + +```bash +git add cmd/plugin_bundle_test.go go.mod go.sum +git commit -s -m "test(plugin): validate plugin manifests, mcp.json and skills" +``` + +--- + +### Task 5: Skills + +**Files:** create `plugin/skills//SKILL.md` for each of the 7 skills below, with the exact content shown. + +- [ ] **Step 1: `plugin/skills/k8s-troubleshooting/SKILL.md`** + +```markdown +--- +name: k8s-troubleshooting +description: Diagnose unhealthy Kubernetes workloads (CrashLoopBackOff, Pending, ImagePullBackOff, OOMKilled, failing readiness, unreachable Services) using the kagent-tools k8s_* MCP tools. Use when a pod, deployment or service is not working as expected. +--- +# Kubernetes troubleshooting + +Work from symptoms to cause. Gather facts before proposing changes. + +## 1. Locate the failing object (read-only) +1. `k8s_get_resources` with `resource_type: pods` and the namespace (`all_namespaces: true` if unknown). Note STATUS, RESTARTS, and READY. +2. `k8s_describe_resource` on the failing pod. Read the **Events** section and the container `State`/`Last State`. +3. `k8s_get_events` for the namespace to find scheduling, pull, or probe failures. + +## 2. Classify +| Symptom | Next step | +|---|---| +| `CrashLoopBackOff`, `Error` | `k8s_get_pod_logs` (also with `previous: true` if supported) for the crashing container | +| `OOMKilled` | Compare limits in `k8s_get_resource_yaml` with usage. Recommend new limits | +| `Pending` | Describe the pod: insufficient CPU/memory, taints, node selectors, or unbound PVCs | +| `ImagePullBackOff` | Check the image name/tag and `imagePullSecrets` in `k8s_get_resource_yaml` | +| Readiness failing | Compare the probe path/port in YAML with the container port. Check logs | +| Service unreachable | `k8s_get_resources` for `endpoints`/`endpointslices`, compare Service selector with pod labels, then `k8s_check_service_connectivity` | + +## 3. Remediate (mutating; confirm with the user first) +Only after the user agrees: `k8s_patch_resource`, `k8s_scale`, `k8s_rollout` (restart/undo), or `k8s_apply_manifest`. Show the exact change before applying it. Afterwards, re-run step 1 and report the result. + +## Notes +- Mutating tools are absent when the server runs read-only. In that case, provide the patch as YAML for the user to apply. +- Do not use `k8s_delete_resource` to "restart" pods; use `k8s_rollout` restart. +``` + +- [ ] **Step 2: `plugin/skills/helm-release-management/SKILL.md`** + +```markdown +--- +name: helm-release-management +description: Inspect, upgrade, roll forward and uninstall Helm releases with the kagent-tools helm_* MCP tools. Use for questions about installed charts, release status, values, failed upgrades, or adding chart repositories. +--- +# Helm release management + +## Inspect (read-only) +1. `helm_list_releases` (namespace or all namespaces) to find the release and its STATUS (`deployed`, `failed`, `pending-*`). +2. `helm_get_release` for values, manifest, notes, or hooks of a single release. +3. To check what the release created, use `k8s_get_resources` in the release namespace. + +## Change (mutating; confirm with the user first) +- Add or refresh chart sources: `helm_repo_add`, then `helm_repo_update`. +- Upgrade or install: `helm_upgrade` with the chart, version, and only the values that change. Restate the chart version and the value diff before running it. +- A release stuck in `pending-upgrade`/`pending-install` usually needs the previous operation to finish or be rolled back. Explain this before acting. +- Remove: `helm_uninstall`, only after explicit confirmation that names the release and namespace. + +## Verify +After any change, run `helm_list_releases` and `k8s_get_resources` for the workload pods. Report the new revision and pod health. +``` + +- [ ] **Step 3: `plugin/skills/istio-traffic-debugging/SKILL.md`** + +```markdown +--- +name: istio-traffic-debugging +description: Debug Istio service-mesh traffic (503s, mTLS failures, routing not applied, sidecar or ambient/waypoint issues) using the kagent-tools istio_* MCP tools. Use when requests between mesh services fail or routing rules do not take effect. +--- +# Istio traffic debugging + +## 1. Mesh health (read-only) +1. `istio_version` for control-plane and data-plane versions. +2. `istio_proxy_status` to check that every proxy is `SYNCED`. A `STALE` or `NOT SENT` proxy points to a control-plane push problem. +3. `istio_analyze_cluster_configuration` for the namespace. Fix reported errors first. + +## 2. Follow one request +1. Identify the source pod and destination service. +2. `istio_proxy_config` on the source pod for `routes`, `clusters`, and `endpoints`. Confirm the destination host and subset exist and endpoints are `HEALTHY`. +3. For ambient mode, use `istio_ztunnel_config`, `istio_list_waypoints`, and `istio_waypoint_status` to confirm the waypoint is attached and programmed. +4. Read the VirtualService, DestinationRule, and AuthorizationPolicy objects with `k8s_get_resource_yaml`. + +## 3. Common causes +| Symptom | Likely cause | +|---|---| +| 503 `UF`/`URX` | No healthy endpoints, or a DestinationRule subset with no matching pod labels | +| 503 `NR` | No route: a VirtualService host or gateway mismatch | +| `RBAC: access denied` | AuthorizationPolicy | +| TLS errors | PeerAuthentication or DestinationRule `tls.mode` mismatch | + +## 4. Change (mutating; confirm first) +Use `istio_apply_waypoint`/`istio_delete_waypoint`, or `k8s_apply_manifest` for Istio CRs. Show the manifest first. Re-run step 2 to verify. +``` + +- [ ] **Step 4: `plugin/skills/argo-rollouts-progressive-delivery/SKILL.md`** + +```markdown +--- +name: argo-rollouts-progressive-delivery +description: Operate Argo Rollouts canary and blue-green releases (status, promote, pause, change image) with the kagent-tools argo_* MCP tools. Use when a Rollout is progressing, paused, degraded, or needs a new image. +--- +# Argo Rollouts progressive delivery + +## Inspect (read-only) +1. `argo_verify_argo_rollouts_controller_install` and `argo_verify_kubectl_plugin_install` once per cluster. +2. `argo_rollouts_list` in the namespace for phase, step, and weight. +3. If a Rollout is `Degraded`, describe it with `k8s_describe_resource` (`resource_type: rollout`) and check AnalysisRun results with `k8s_get_resources` (`resource_type: analysisrun`). + +## Act (mutating; confirm with the user first) +- Start a release: `argo_set_rollout_image` with the container name and the full image reference. +- Hold: `argo_pause_rollout`. +- Advance one step or fully promote: `argo_promote_rollout`. State which applies before running it. +- Gateway API traffic routing: `argo_verify_gateway_plugin` before the first release. + +## Verify +After each action, run `argo_rollouts_list` again. Report the step and weight, and stop if analysis fails. +``` + +- [ ] **Step 5: `plugin/skills/cilium-network-debugging/SKILL.md`** + +```markdown +--- +name: cilium-network-debugging +description: Diagnose Cilium CNI networking (agent health, endpoint and identity state, network policy drops, services, BGP, cluster mesh) with the kagent-tools cilium_* MCP tools. Use when pods cannot reach each other or services, or a policy blocks traffic unexpectedly. +--- +# Cilium network debugging + +## 1. Health (read-only) +1. `cilium_status_and_version` for an overall status and version. +2. `cilium_get_daemon_status` on the node that hosts the affected pod. +3. `cilium_list_cluster_nodes` to confirm all nodes are known. + +## 2. Endpoint and identity +1. `cilium_get_endpoints_list`, then `cilium_get_endpoint_details` for the source and destination pods. Check that they are in the `ready` state. +2. `cilium_get_identity_details` for both identities. Check that their labels match what the policy selects. +3. `cilium_display_selectors` and `cilium_display_policy_node_information` to see which policies apply. +4. `cilium_validate_cilium_network_policies` to catch invalid policies. + +## 3. Services and routing +- `cilium_list_services` / `cilium_get_service_information` for ClusterIP and backend mapping. +- `cilium_list_bgp_peers` / `cilium_list_bgp_routes` for BGP setups. +- `cilium_show_cluster_mesh_status` for multi-cluster setups. + +## 4. Change (mutating; confirm first) +Prefer fixing the CiliumNetworkPolicy manifest through `k8s_apply_manifest` over imperative cilium state changes. Never run `cilium_uninstall_cilium` or `cilium_flush_ipsec_state` without explicit, specific confirmation. +``` + +- [ ] **Step 6: `plugin/skills/kubescape-security-posture/SKILL.md`** + +```markdown +--- +name: kubescape-security-posture +description: Review cluster security posture with Kubescape results (configuration scans, image vulnerabilities, application profiles, network neighborhoods) using the kagent-tools kubescape_* MCP tools. Use for security reviews, CVE questions, or hardening recommendations. +--- +# Kubescape security posture + +## 1. Preconditions +`kubescape_check_health` must report the operator and storage as healthy. Otherwise, explain that the Kubescape operator must be installed first. + +## 2. Configuration risks +1. `kubescape_list_configuration_scans` in the namespace (or cluster-wide). +2. `kubescape_get_configuration_scan` for a workload. Group failed controls by severity and give the YAML fix for each. + +## 3. Vulnerabilities +1. `kubescape_list_vulnerability_manifests` / `kubescape_list_vulnerabilities`, filtered to Critical and High first. +2. `kubescape_get_vulnerability_details` for a CVE: affected package, fixed version, and which workloads use the image. + +## 4. Runtime behaviour +`kubescape_list_application_profiles` / `kubescape_get_application_profile` and `kubescape_list_network_neighborhoods` / `kubescape_get_network_neighborhood` show the observed syscalls, processes, and connections. Use them to propose least-privilege seccomp or NetworkPolicies. + +Report findings as a prioritised table with severity, resource, issue, and fix. This skill is read-only; apply fixes only through other skills after confirmation. +``` + +- [ ] **Step 7: `plugin/skills/prometheus-observability/SKILL.md`** + +```markdown +--- +name: prometheus-observability +description: Answer questions about cluster and application metrics (error rates, latency, saturation, scrape health) with the kagent-tools prometheus_* MCP tools. Use when investigating performance, alerts, or whether a target is scraped. Requires PROMETHEUS_URL to be configured. +--- +# Prometheus observability + +## 1. Check connectivity +`prometheus_targets_tool` lists scrape targets. If it fails, tell the user that `PROMETHEUS_URL` (and an EgressPolicy for it when running inside a kagent agent) must be configured. + +## 2. Discover before querying +`prometheus_label_names_tool` shows the available labels, so queries use real label names. + +## 3. Query +- Current value: `prometheus_query_tool`. Example: `sum(rate(http_requests_total{namespace="shop",code=~"5.."}[5m])) by (service)`. +- Trend: `prometheus_query_range_tool` with a range and step (for example, 1h with a 60s step). +- Draft complex PromQL with `prometheus_promql_tool`, then run it. + +## 4. Interpret +Use RED for services (rate, errors, duration) and USE for resources (utilisation, saturation, errors). Always state the query you ran and the time window. Correlate spikes with `k8s_get_events` and rollouts. +``` + +- [ ] **Step 8: Run the plugin tests** + +Run: `go test ./cmd -run 'TestPlugin' -v` +Expected: PASS. If a tool name is reported unknown, check the exact name in `pkg/` `RegisterTools` and fix the SKILL.md. Do not rename tools. + +- [ ] **Step 9: Commit** + +```bash +git add plugin/skills +git commit -s -m "feat(plugin): add operational skills for each tool provider" +``` + +--- + +### Task 6: Dockerfile — dual-use image with plugin roots + +**Files:** Modify `Dockerfile` (builder `go build` line and the whole final stage). + +- [ ] **Step 1: Strip the server binary** + +In the builder stage, change the build command to: + +```dockerfile + CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -trimpath -ldflags "-s -w $LDFLAGS" -o tool-server ./cmd +``` + +- [ ] **Step 2: Add an assembly stage** (insert before the final `FROM gcr.io/distroless/static:nonroot`) + +```dockerfile +### STAGE 3: assemble kagent Agent Plugin roots +FROM tools AS plugin +ARG VERSION=dev +COPY plugin /src/plugin +COPY --from=builder /workspace/tool-server /downloads/kagent-tools +RUN set -eu; \ + root=/out/opt/kagent-tools; skills_root=/out/opt/kagent-tools-skills; \ + plugin_version="${VERSION#v}"; \ + mkdir -p "$root/bin" "$root/config" "$skills_root" /out/usr/bin; \ + for binary in kagent-tools kubectl helm istioctl kubectl-argo-rollouts cilium; do \ + install -m 0755 "/downloads/$binary" "$root/bin/$binary"; \ + done; \ + sed "s/__VERSION__/${plugin_version}/" /src/plugin/plugin.json.tmpl > "$root/plugin.json"; \ + sed "s/__VERSION__/${plugin_version}/" /src/plugin/skills-plugin.json.tmpl > "$skills_root/plugin.json"; \ + install -m 0644 /src/plugin/mcp.json "$root/mcp.json"; \ + install -m 0644 /src/plugin/config/kubeconfig "$root/config/kubeconfig"; \ + cp -R /src/plugin/skills "$root/skills"; \ + cp -R /src/plugin/skills "$skills_root/skills"; \ + find /out/opt -type d -exec chmod 0755 {} +; \ + find /out/opt -type f ! -path '*/bin/*' -exec chmod 0644 {} +; \ + ln -s /opt/kagent-tools/bin/kagent-tools /out/tool-server; \ + for binary in kubectl helm istioctl kubectl-argo-rollouts cilium; do \ + ln -s "/opt/kagent-tools/bin/$binary" "/out/usr/bin/$binary"; \ + done; \ + chown -R 65532:65532 /out/opt +``` + +- [ ] **Step 3: Replace the final stage** + +```dockerfile +FROM gcr.io/distroless/static:nonroot + +WORKDIR / +USER 65532:65532 +ENV PATH=/opt/kagent-tools/bin:$PATH:/bin + +# /opt/kagent-tools -> kagent Agent Plugin root (CLIs + stdio MCP server + skills) +# /opt/kagent-tools-skills -> skills-only Agent Plugin root +# /tool-server, /bin/* -> backward-compatible symlinks (distroless /bin -> usr/bin) +COPY --from=plugin /out/ / + +ARG VERSION + +LABEL org.opencontainers.image.source=https://github.com/kagent-dev/tools +LABEL org.opencontainers.image.description="Kagent MCP tools server and kagent Agent Plugin (skills bundle)" +LABEL org.opencontainers.image.authors="Kagent Creators 🤖" +LABEL org.opencontainers.image.version="$VERSION" + +ENTRYPOINT ["/opt/kagent-tools/bin/kagent-tools"] +``` + +The `tools` stage already downloads `kubectl`, `helm`, `istioctl`, `kubectl-argo-rollouts`, and `cilium` into `/downloads`. Wolfi provides `install`, `sed`, `find`, and `ln`. + +- [ ] **Step 4: Build and check basic behaviour** + +Run: `make docker-build` +Expected: the build succeeds. + +Run: `IMG=$(make -s -n docker-build | grep -oE -- '-t [^ ]+' | head -1 | cut -d' ' -f2); docker run --rm "$IMG" --version` +Expected: the version block is printed. + +Run: `docker run --rm --entrypoint /tool-server "$IMG" --version` +Expected: the same output, so the legacy path works. + +- [ ] **Step 5: Commit** + +```bash +git add Dockerfile +git commit -s -m "feat(image): lay out image as kagent Agent Plugin with legacy symlinks" +``` + +--- + +### Task 7: Image verification (layout, limits, stdio smoke) + `make plugin-verify` + +**Files:** +- Create: `cmd/plugin_image_test.go` +- Create: `scripts/verify-plugin-image.sh` +- Modify: `Makefile` (new target after `docker-build`) + +- [ ] **Step 1: Create `cmd/plugin_image_test.go`** (skipped unless `KAGENT_TOOLS_IMAGE_ROOT` is set) + +```go +package main + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + kagentMaxPackageEntries = 10_000 + kagentMaxPackageBytes = 100 << 20 +) + +type packageStats struct { + Entries int64 + Bytes int64 +} + +// walkPluginPackage mirrors kagent's validatePackage checks for one plugin root. +func walkPluginPackage(t *testing.T, root string) packageStats { + t.Helper() + var stats packageStats + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + require.NoError(t, err) + stats.Entries++ + info, err := entry.Info() + require.NoError(t, err) + mode := info.Mode() + require.Truef(t, mode.IsRegular() || mode.IsDir() || mode&os.ModeSymlink != 0, "unsupported file %s (%s)", path, mode) + if mode.IsRegular() { + stats.Bytes += info.Size() + } + if mode&os.ModeSymlink != 0 { + resolved, err := filepath.EvalSymlinks(path) + require.NoError(t, err) + require.Truef(t, strings.HasPrefix(resolved, root+string(filepath.Separator)), "symlink %s escapes %s", path, root) + } + return nil + }) + require.NoError(t, err) + return stats +} + +func TestPluginImageLayout(t *testing.T) { + imageRoot := os.Getenv("KAGENT_TOOLS_IMAGE_ROOT") + if imageRoot == "" { + t.Skip("set KAGENT_TOOLS_IMAGE_ROOT to an exported image filesystem (scripts/verify-plugin-image.sh)") + } + + fullRoot := filepath.Join(imageRoot, "opt", "kagent-tools") + for _, binary := range []string{"kagent-tools", "kubectl", "helm", "istioctl", "kubectl-argo-rollouts", "cilium"} { + info, err := os.Stat(filepath.Join(fullRoot, "bin", binary)) + require.NoError(t, err, binary) + require.NotZero(t, info.Mode().Perm()&0o111, "%s must be executable", binary) + } + for _, file := range []string{"plugin.json", "mcp.json", "config/kubeconfig"} { + require.FileExists(t, filepath.Join(fullRoot, file)) + } + fullStats := walkPluginPackage(t, fullRoot) + require.Less(t, fullStats.Entries, int64(kagentMaxPackageEntries)) + t.Logf("full plugin root: %d entries, %d MiB (requires kagent mounted-package size exemption)", fullStats.Entries, fullStats.Bytes>>20) + + skillsRoot := filepath.Join(imageRoot, "opt", "kagent-tools-skills") + require.FileExists(t, filepath.Join(skillsRoot, "plugin.json")) + require.NoFileExists(t, filepath.Join(skillsRoot, "mcp.json")) + skillsStats := walkPluginPackage(t, skillsRoot) + require.Less(t, skillsStats.Entries, int64(kagentMaxPackageEntries)) + require.Less(t, skillsStats.Bytes, int64(kagentMaxPackageBytes)) + + fullSkills, err := os.ReadDir(filepath.Join(fullRoot, "skills")) + require.NoError(t, err) + onlySkills, err := os.ReadDir(filepath.Join(skillsRoot, "skills")) + require.NoError(t, err) + require.Equal(t, len(fullSkills), len(onlySkills)) + + target, err := os.Readlink(filepath.Join(imageRoot, "tool-server")) + require.NoError(t, err) + require.Equal(t, "/opt/kagent-tools/bin/kagent-tools", target) +} +``` + +- [ ] **Step 2: Create `scripts/verify-plugin-image.sh`** (`chmod +x`) + +```bash +#!/usr/bin/env bash +# Verifies that a built kagent-tools image is a valid kagent Agent Plugin. +set -euo pipefail + +image="${1:?usage: $0 }" +workdir="$(mktemp -d)" +container="" +cleanup() { + [[ -n "$container" ]] && docker rm -f "$container" >/dev/null 2>&1 || true + rm -rf "$workdir" +} +trap cleanup EXIT + +container="$(docker create "$image")" +docker export "$container" | tar -x -C "$workdir" + +KAGENT_TOOLS_IMAGE_ROOT="$workdir" go test ./cmd -run TestPluginImageLayout -count=1 -v + +echo "--- stdio smoke test" +# Keep stdin open briefly: the server exits on EOF before answering. +response="$({ printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'; sleep 5; } \ + | docker run -i --rm --entrypoint /opt/kagent-tools/bin/kagent-tools "$image" --stdio 2>/dev/null)" + +for tool in k8s_get_resources helm_list_releases istio_proxy_status argo_rollouts_list cilium_status_and_version; do + grep -q "\"name\":\"$tool\"" <<<"$response" || { echo "missing tool $tool in stdio tools/list"; exit 1; } +done +if grep -qv '^{"jsonrpc"' <<<"$response"; then + echo "non JSON-RPC output on stdout:"; grep -v '^{"jsonrpc"' <<<"$response" | head -5; exit 1 +fi +echo "plugin image OK: $image" +``` + +- [ ] **Step 3: Add the Makefile target** (after the `docker-build` target) + +```makefile +.PHONY: plugin-verify +plugin-verify: docker-build ## Verify the image is a valid kagent Agent Plugin (layout, limits, stdio) + ./scripts/verify-plugin-image.sh $(TOOLS_IMG) +``` + +- [ ] **Step 4: Run it** + +Run: `make plugin-verify` +Expected: `TestPluginImageLayout` PASS, the log line reports ~650 MiB for the full root, and the last line is `plugin image OK: …`. + +Run: `go test ./cmd -run TestPluginImageLayout -v` +Expected: SKIP (no env set), so `make test` is unaffected. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/plugin_image_test.go scripts/verify-plugin-image.sh Makefile +git commit -s -m "test(plugin): verify image layout, kagent limits and stdio protocol" +``` + +--- + +### Task 8: CI + +**Files:** Modify `.github/workflows/ci.yaml`. The existing `build` job exports multi-arch output to `/dev/null`, so add a separate single-arch job that loads the image. + +- [ ] **Step 1: Add the job** (after `go-unit-tests`) + +```yaml + plugin-verify: + env: + VERSION: v0.0.1-test + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + cache: false + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + version: v0.23.0 + use: 'true' + - name: Verify kagent Agent Plugin image + env: + DOCKER_BUILDKIT: 1 + BUILDX_BUILDER_NAME: kagent-builder-v0.23.0 + DOCKER_BUILD_ARGS: >- + --cache-from=type=gha + --load + --platform=linux/amd64 + DOCKER_REPO: "${{ github.repository_owner }}/tools" + DOCKER_BUILDER: "docker buildx" + run: make plugin-verify +``` + +- [ ] **Step 2: Validate the workflow syntax** + +Run: `docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest .github/workflows/ci.yaml` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/ci.yaml +git commit -s -m "ci: verify kagent Agent Plugin image layout" +``` + +--- + +### Task 9: Documentation + +**Files:** +- Create: `docs/kagent-skills.md` +- Modify: `README.md` (add a "Use as kagent skills bundle" link in the usage section) +- Modify: `AGENTS.md` (add `plugin/` to Repository Structure; add a "Plugin bundle" note under Adding a New Tool: "new tool providers should add or extend a skill in `plugin/skills` and keep `cmd/plugin_bundle_test.go` passing") + +- [ ] **Step 1: Write `docs/kagent-skills.md`** with these sections, in this order: + 1. **What you get:** the two plugin roots (`opt/kagent-tools`, `opt/kagent-tools-skills`) and the 7 skills with one-line descriptions (copy from the frontmatter). + 2. **Requirements:** kagent ≥ the release that contains the Task 0 change, the Go ADK (`kagent`) harness, the image pinned by digest (`docker buildx imagetools inspect ghcr.io/kagent-dev/kagent/tools:` prints it). + 3. **Cluster access:** the ServiceAccount, token Secret, ClusterRoleBinding (`view` example), and EgressPolicy manifests from spec §4.4, verbatim. + 4. **Agent:** the AgentTemplate + Agent manifests from spec §4.4, verbatim. + 5. **Tuning via Harness env:** `KAGENT_TOOLS_READ_ONLY=true`, `KAGENT_TOOLS_TOOLS=k8s,helm`, `PROMETHEUS_URL=…` (plus an EgressPolicy origin for it), and `OTEL_EXPORTER_OTLP_ENDPOINT`. + 6. **Skills-only mode (current kagent):** an AgentTemplate with `path: opt/kagent-tools-skills` plus a `RemoteMCPServer` pointing at the Helm-deployed server. Warn: never select both roots in one agent. + 7. **Troubleshooting:** `artifact exceeds 104857600 bytes` means kagent lacks the Task 0 change, so use skills-only mode. A 401/403 from tools means the EgressPolicy/Secret/RBAC is wrong. A connection refused or blocked error means the EgressPolicy is missing from `egressPolicyRefs`. `exec`/streamed logs are best effort through the egress gateway. "Plugin is Claude-format" cannot happen with this image, because `plugin.json` sits at the root. + +- [ ] **Step 2: Update the README and AGENTS.md** as described in **Files** above. + +- [ ] **Step 3: Run the full repo checks** + +Run: `make fmt && make lint && make test` +Expected: all pass. `go test -cover ./cmd` must not lower coverage, because Tasks 1, 4, and 7 add tests. + +- [ ] **Step 4: Commit** + +```bash +git add docs/kagent-skills.md README.md AGENTS.md +git commit -s -m "docs: using kagent-tools as a kagent 1.x skills bundle" +``` + +--- + +### Task 10 (opt-in): E2E on kagent 1.x + +**Files:** Create `test/e2e/kagent_plugin/manifests.yaml` (spec §4.4 manifests with `IMAGE_DIGEST` substituted by `envsubst`) and `test/e2e/kagent_plugin/run.sh`. + +- [ ] **Step 1: Write `run.sh`**, which: + (a) requires `KAGENT_V1_E2E=1`, `KUBECONFIG` pointing to a Kind cluster with kagent ≥ the Task 0 release, and `IMAGE` (pushed, digest-pinned); + (b) runs `envsubst < manifests.yaml | kubectl apply -f -`; + (c) waits with `kubectl wait --for=condition=Ready agent/k8s-operator -n kagent --timeout=300s`; + (d) invokes the agent through the kagent CLI or A2A endpoint with the prompt "Use list_skills, then list pods in kube-system using your tools" and greps the response for `k8s-troubleshooting` and `coredns`; + (e) best effort: asks for the logs of one coredns pod, and prints a WARNING instead of failing when it gets no output. + +- [ ] **Step 2: Run it against a local Kind + kagent install**, record the results (including the streaming behaviour) in `docs/kagent-skills.md` § Troubleshooting, and commit. + +```bash +git add test/e2e/kagent_plugin docs/kagent-skills.md +git commit -s -m "test(e2e): opt-in kagent 1.x plugin bundle scenario" +``` + +--- + +## Spec coverage check + +| Spec section | Task | +|---|---| +| §4.1 layout, symlinks, both roots | 6, 7 | +| §4.2 mcp.json | 3, 4 | +| §4.3 env fallbacks, kubeconfig chain, KUBECONFIG export, stdio stderr fix | 1, 2 | +| §4.4 kubeconfig + RBAC/EgressPolicy docs | 3, 9 | +| §4.5 skills + tool-name validation | 4, 5 | +| §5 behaviour, fallback, troubleshooting | 9 | +| §6 upstream change | 0 | +| §7 testing layers | 1, 2, 4, 7, 8, 10 | diff --git a/docs/superpowers/specs/2026-10-24-kagent-tools-skills-bundle-design.md b/docs/superpowers/specs/2026-10-24-kagent-tools-skills-bundle-design.md new file mode 100644 index 00000000..3bde05c1 --- /dev/null +++ b/docs/superpowers/specs/2026-10-24-kagent-tools-skills-bundle-design.md @@ -0,0 +1,312 @@ +# kagent-tools as an OCI Skills Bundle for kagent 1.x — Design + +Status: Draft for review · Date: 2026-10-24 · Target: kagent `v1.0.0-alpha7`+ (`api.kagent.dev/v1alpha3`, Substrate runtime) + +## 1. Goal + +Ship **one OCI image** from this repository that works in two roles: + +1. **MCP server** (today): `Deployment` + `Service`, consumed by kagent as a `RemoteMCPServer`. +2. **kagent 1.x skills bundle** (new): referenced from an `AgentTemplate` as a digest-pinned + `spec.plugins[].source.oci`. kagent mounts the image into the agent Actor, installs the + selected skills, and starts the bundled `kagent-tools` binary as a **stdio MCP server inside + the agent**. The binary drives the bundled CLIs: `kubectl`, `helm`, `istioctl`, + `kubectl-argo-rollouts`, and `cilium`. + +Success criteria: + +- A Go ADK agent that references only the image (no `RemoteMCPServer`) can list skills, + load a skill, and call `k8s_*`, `helm_*`, `istio_*`, `argo_*`, and `cilium_*` tools against its cluster. +- The existing Helm chart and `RemoteMCPServer` usage keep working with the same image. +- Least privilege is enforced by Kubernetes RBAC on an injected ServiceAccount token. No + credential enters the image or the agent environment. + +Non-goals: Python ADK support (kagent's Python ADK does not consume the `agent_plugins` +contract yet), Claude-format plugins, Git/S3 delivery, and a new kagent CLI command. + +## 2. How kagent 1.x consumes OCI skills (verified in `kagent-dev/kagent@e8eee57`) + +| Fact | Source | +|---|---| +| `AgentTemplate.spec.skills[]` (`{name, source}`) and `spec.plugins[]` (`{source, skills[]}`); `source.oci` must be `repo@sha256:…`; `source.path` selects a sub-directory | `go/api/v1alpha3/agenttemplate_types.go` | +| Each distinct image is a read-only **image volume** at `/run/kagent/artifacts/`. The entrypoint is **never run**, and there is no in-runtime pull | `go/core/internal/substrate/actor_template.go`, `translator/skills.go` | +| Files outside `source.path` are ignored | `docs/architecture/configuration-and-compilation.md` | +| Agent Plugin root = `plugin.json` with `$schema: https://agent-plugins.org/schemas/1.0.0/plugin.schema.json`; selected skills are copied from `/skills/` to `/skills/` | `go/core/pkg/agentplugins/materialize.go` | +| `/mcp.json` (`$schema …/mcp.schema.json`) may declare **stdio** servers: `command` bare or `./`-relative, `${PLUGIN_ROOT}`/`${PLUGIN_DATA}` expanded in `args`/`env`, `cwd` defaults to the plugin root, env is **appended to the runtime env** (last wins) | `materialize.go` `parseMCPServer`, `go/adk/pkg/mcp/registry.go` | +| Only the **Go ADK** (`kagent` harness) loads plugin `mcp.json`. Codex/Claude install the skills only | `go/adk/pkg/config/agentplugins.go` | +| Package validation per selected root: **≤ 10,000 entries, ≤ 100 MiB of regular files**, only files/dirs/symlinks, symlinks must stay inside the root | `materialize.go` `validatePackage` | +| Runtime images ship only `bash ca-certificates git`; there is no `kubectl` or `helm` | `go/Dockerfile` | +| Egress is deny-by-default. `EgressPolicy` grants origins and can inject a Secret into a header through a MITM gateway whose CA is at `/run/kagent/egress/trust-bundle.pem`. The gateway replaces a header; it never adds one | `docs/architecture/egress-policies.md`, `credential-injection.md` | +| Up to 29 distinct skill images per Actor | `configuration-and-compilation.md` | + +### Measured sizes (linux/amd64) + +| File | Size | +|---|---| +| `kagent-tools` (`-s -w -trimpath`) | 74 MB | +| `kubectl` 1.37.0 | 62 MB | +| `helm` 4.3.0 | ~70 MB | +| `istioctl` 1.31.1 | ~100 MB | +| `kubectl-argo-rollouts` 1.10.0 | 142 MB | +| `cilium` 0.20.0 | ~200 MB | +| **Plugin root total** | **~650 MB** | + +**Blocking constraint:** the full bundle exceeds kagent's 100 MiB package limit (`kagent-tools` +plus `kubectl` alone is 136 MB). The bundle **requires an upstream kagent change** (§6). A +skills-only plugin root, which fits today, provides the fallback (§5.5). + +## 3. Approaches considered + +| | Approach | Pros | Cons | +|---|---|---|---| +| **A (chosen)** | **Dual-use image**: restructure the existing distroless image so everything lives under the Agent Plugin root `/opt/kagent-tools`; legacy paths become symlinks | One image and one digest for both roles; no drift between server and bundle; matches the request | Needs the upstream size-limit change; adds ~2 MB of distroless files to the volume (ignored) | +| B | Separate `FROM scratch` `tools-plugin` image with the same contents | Matches kagent's "build from scratch" guidance literally | Two images to release and pin, and the same upstream change is still required | +| C | Skills-only plugin plus the existing `RemoteMCPServer` | Works on current kagent | CLIs are not in the bundle; still needs a separate Deployment | + +A is chosen. C ships **inside the same image** as a second plugin root, so the image is useful +before the upstream change lands. + +## 4. Architecture + +```mermaid +flowchart LR + subgraph Image["ghcr.io/kagent-dev/kagent/tools@sha256:… (one image)"] + direction TB + P["/opt/kagent-tools (full plugin root)
plugin.json · mcp.json · bin/* · config/kubeconfig · skills/*"] + S["/opt/kagent-tools-skills (skills-only root)
plugin.json · skills/*"] + L["/tool-server, /bin/* → symlinks into /opt/kagent-tools/bin"] + end + Image -- "role 1: ENTRYPOINT, HTTP" --> Dep["Deployment + Service
(RemoteMCPServer)"] + Image -- "role 2: image volume, entrypoint not run" --> Actor + subgraph Actor["kagent Actor (Go ADK)"] + M["materializer: copy selected skills → /skills/<name>"] + T["stdio MCP: PLUGIN_ROOT/bin/kagent-tools --stdio"] + C["kubectl / helm / istioctl / argo / cilium
(PATH = PLUGIN_ROOT/bin)"] + T --> C + end + C -- "Authorization: Bearer placeholder" --> GW["Substrate egress gateway
(EgressPolicy injects SA token)"] + GW --> API["kube-apiserver
https://kubernetes.default.svc"] +``` + +### 4.1 Image layout + +``` +/opt/kagent-tools/ # full plugin root (source.path: opt/kagent-tools) + plugin.json # name "kagent-tools", version = image VERSION + mcp.json # stdio server "kagent-tools" + bin/kagent-tools # the MCP server (stripped) + bin/kubectl bin/helm bin/istioctl bin/kubectl-argo-rollouts bin/cilium + config/kubeconfig # egress-gateway kubeconfig (no secrets) + skills//SKILL.md # + optional references/*.md +/opt/kagent-tools-skills/ # skills-only root (source.path: opt/kagent-tools-skills) + plugin.json # name "kagent-tools-skills" + skills//... # identical copy of the skills +/tool-server -> /opt/kagent-tools/bin/kagent-tools # backward compatibility +/bin/{kubectl,helm,...} -> /opt/kagent-tools/bin/... # backward compatibility +ENV PATH=/opt/kagent-tools/bin:$PATH +ENTRYPOINT ["/opt/kagent-tools/bin/kagent-tools"] +``` + +The legacy symlinks sit **outside** both plugin roots, so they never violate kagent's +"symlinks must stay inside root" rule. All files are owned by `65532:65532` with mode `0755` +for binaries and `0644` for data. Both plugin roots hold only regular files and dirs. + +Source of truth in the repo: a new top-level `plugin/` directory (`plugin.json.tmpl`, +`skills-plugin.json.tmpl`, `mcp.json`, `config/kubeconfig`, `skills/`). The Dockerfile +renders the manifests with `VERSION` in a build stage and assembles both roots. + +### 4.2 `mcp.json` + +```json +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "kagent-tools": { + "type": "stdio", + "command": "./bin/kagent-tools", + "args": ["--stdio"], + "env": { + "PATH": "${PLUGIN_ROOT}/bin:/usr/local/bin:/usr/bin:/bin", + "KAGENT_TOOLS_DEFAULT_KUBECONFIG": "${PLUGIN_ROOT}/config/kubeconfig", + "KAGENT_USE_STDERR": "true", + "HOME": "${PLUGIN_DATA}", + "XDG_CACHE_HOME": "${PLUGIN_DATA}/cache", + "XDG_CONFIG_HOME": "${PLUGIN_DATA}/config", + "XDG_DATA_HOME": "${PLUGIN_DATA}/data" + } + } + } +} +``` + +- The plugin root is a read-only mount, so every CLI cache and config directory (helm repos, + kubectl discovery cache, istioctl) is redirected to the writable `${PLUGIN_DATA}`. +- Stdout carries the MCP protocol. Logs go to stderr (`KAGENT_USE_STDERR`). +- Because `mcp.json` env overrides the runtime env, it sets only **defaults** + (`KAGENT_TOOLS_DEFAULT_*`). Operator-tunable settings come from Harness env vars that + `mcp.json` leaves untouched (§4.3). + +### 4.3 `kagent-tools` server changes + +All changes are backward compatible: + +1. **Env fallbacks for flags.** If a flag is not set explicitly, read it from the environment: + `KAGENT_TOOLS_TOOLS` (comma-separated) → `--tools`, `KAGENT_TOOLS_READ_ONLY` → `--read-only`. + Operators tune the in-agent server through Harness env without rebuilding the image. + Precedence: flag > env > default. +2. **Kubeconfig resolution:** `--kubeconfig` > `KUBECONFIG` > `KAGENT_TOOLS_DEFAULT_KUBECONFIG` + > in-cluster (empty). When the resolved path is non-empty, the server exports it as + `KUBECONFIG` for its own process, because today only the `k8s` and `kubescape` providers + receive the flag. Every child CLI (helm, istioctl, cilium, argo) then uses the same cluster. + This also fixes a latent inconsistency for HTTP-mode users who pass `--kubeconfig`. +3. Logic lives in `cmd/` as a small, unit-tested `resolveServerConfig(flags, lookupEnv)` + function that returns a typed config struct. No new public packages. +4. **Fix the stdio stream corruption.** When no `OTEL_EXPORTER_OTLP_ENDPOINT` is set, + `internal/telemetry` falls back to `stdouttrace`, which writes spans to **stdout** and breaks + the MCP stdio protocol. In stdio mode, the console exporter must write to stderr. + `run()` sets `KAGENT_USE_STDERR=true` when `--stdio` is set, and `createExporter` writes the + console exporter to stderr when that variable is `true`. + +### 4.4 Cluster access from the Actor + +`config/kubeconfig` (static, in the image): + +```yaml +apiVersion: v1 +kind: Config +clusters: +- name: in-cluster + cluster: + server: https://kubernetes.default.svc + certificate-authority: /run/kagent/egress/trust-bundle.pem +users: +- name: kagent-egress + user: + token: kagent-credential-injected +contexts: +- name: default + context: {cluster: in-cluster, user: kagent-egress} +current-context: default +``` + +User-side setup (documented in `docs/kagent-skills.md` with a complete example): + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: {name: kagent-tools-agent, namespace: kagent} +--- +apiVersion: v1 +kind: Secret +metadata: + name: kagent-tools-agent-token + namespace: kagent + annotations: {kubernetes.io/service-account.name: kagent-tools-agent} +type: kubernetes.io/service-account-token +--- +# Bind a ClusterRole: "view" for read-only agents, a custom role for write tools. +apiVersion: api.kagent.dev/v1alpha3 +kind: EgressPolicy +metadata: {name: kube-api, namespace: kagent} +spec: + destinations: + - origin: https://kubernetes.default.svc + authentication: + header: Authorization + prefix: "Bearer " + secretKeyRef: {name: kagent-tools-agent-token, key: token} +--- +apiVersion: api.kagent.dev/v1alpha3 +kind: AgentTemplate +metadata: {name: k8s-operator, namespace: kagent} +spec: + systemPrompt: "Use the kagent-tools skills and tools to operate the cluster." + plugins: + - source: + oci: ghcr.io/kagent-dev/kagent/tools@sha256: + path: opt/kagent-tools + skills: [k8s-troubleshooting, helm-release-management] +--- +apiVersion: api.kagent.dev/v1alpha3 +kind: Agent +metadata: {name: k8s-operator, namespace: kagent} +spec: + template: {ref: {name: k8s-operator}} + harnessRef: {name: kagent} # Go ADK harness + egressPolicyRefs: [{name: kube-api}] +``` + +Security properties: the token never enters the guest; RBAC on `kagent-tools-agent` is the real +authorization boundary; setting `KAGENT_TOOLS_READ_ONLY=true` on the Harness also hides write +tools from the model. + +### 4.5 Skills (initial set, one per provider) + +Each skill is an `agentskills.io`-compliant `SKILL.md`: `name` matches the directory and the +pattern `^[a-z0-9]+(-[a-z0-9]+)*$` (≤ 64 chars), and `description` is ≤ 1024 chars and states +*when* to use the skill. The skill bodies are **playbooks that call the MCP tool names**, not +shell scripts. The bash tool's `PATH` does not include the plugin `bin/`, and a skill cannot +know the hashed mount path. Tool names are identical in plugin mode and RemoteMCPServer mode, +so the same skills serve both roots. + +| Skill | Primary tools (exact names verified against `RegisterTools` during implementation) | +|---|---| +| `k8s-troubleshooting` | `k8s_*` get/describe/logs/events tools | +| `helm-release-management` | `helm_list_releases`, `helm_get_release`, `helm_upgrade`, `helm_uninstall`, `helm_repo_add`, `helm_repo_update` | +| `istio-traffic-debugging` | `istio_proxy_status`, `istio_proxy_config`, `istio_analyze_cluster_configuration`, waypoint tools | +| `argo-rollouts-progressive-delivery` | `argo_rollouts_list`, `argo_promote_rollout`, `argo_pause_rollout`, `argo_set_rollout_image` | +| `cilium-network-debugging` | `cilium_status_and_version`, endpoint, identity, policy, and BGP tools | +| `kubescape-security-posture` | `kubescape_*` | +| `prometheus-observability` | `prometheus_*` (needs `PROMETHEUS_URL` on the Harness env and an EgressPolicy) | + +Each skill separates read-only from mutating steps and tells the model to confirm with the user +before any mutating call. + +## 5. Behaviour and error handling + +1. **Package rejection:** if the materializer rejects the package (size, symlink, schema), + kagent fails the agent with the materializer error. The bundle must not trip anything except + the size limit (§6), and CI checks the rest (§7). +2. **Missing EgressPolicy or RBAC:** CLIs return connection or 401/403 errors. These surface as + existing tool errors. `docs/kagent-skills.md` § Troubleshooting maps these errors to the + EgressPolicy, Secret, and RBAC setup. No code change is needed. +3. **Streaming operations** (`exec`, `logs -f`, `port-forward`) need HTTP upgrade through the + gateway. These are unverified, so the e2e test checks them, and the docs list them as + *best effort* until confirmed. +4. **Unsupported harness** (Python ADK, Codex, Claude): skills install, but no MCP tools start. + The docs point those users at the skills-only root plus `RemoteMCPServer`. +5. **Skills-only fallback:** on kagent builds without the §6 change, use + `path: opt/kagent-tools-skills` with the existing Helm-deployed server as a `RemoteMCPServer`. + **Do not** select both roots in one agent, because duplicate skill names make kagent reject + the selection. + +## 6. Upstream dependency (kagent PR) + +In `go/core/pkg/agentplugins/materialize.go`, skip `maxPackageBytes` for **platform-mounted** +sources (`source.Directory != ""`). These are read-only image volumes that are never copied or +downloaded by the runtime. Keep the entry-count, file-type, and symlink checks. Bytes copied +into `/skills` stay bounded by applying the existing 100 MiB budget inside `copySkill`. This +keeps protection where data is actually copied and lets large toolchain bundles through. +Track it as a prerequisite issue in `kagent-dev/kagent`. Until it merges, only the +skills-only root is usable. + +## 7. Testing + +| Layer | What | +|---|---| +| Unit (`cmd/`) | `resolveServerConfig` precedence table: flags, env fallbacks, kubeconfig chain, `KUBECONFIG` export | +| Unit (`plugin/plugin_test.go`) | Parse `plugin.json`/`mcp.json` with `DisallowUnknownFields` into typed structs mirroring kagent's; check schemas, the name pattern, `./`-relative command, env keys (no `PLUGIN_ROOT`/`PLUGIN_DATA` overrides); every `skills/*/SKILL.md` frontmatter is valid, `name` matches its directory, and every referenced tool name exists in the registered tool set | +| Image (`make plugin-verify`) | Build the image, `docker create` + `docker export`, then assert: both roots exist, binaries are executable, no symlink escapes either root, entries < 10,000, the skills-only root is < 100 MiB, `/tool-server --version` still works | +| Stdio smoke | Run `/opt/kagent-tools/bin/kagent-tools --stdio` in the image with MCP `initialize` + `tools/list` over stdin. Assert the tool list is non-empty | +| E2E (`test/e2e`, opt-in) | On a kagent 1.x Kind install with the §6 patch: apply the §4.4 manifests, invoke the agent, assert `list_skills` contains the selected skills and a `k8s_get_resources` call succeeds; best-effort check of pod logs | +| Regression | Existing Helm tests and e2e on the restructured image, with no chart changes needed | + +## 8. Risks and open questions + +| Risk | Mitigation | +|---|---| +| The upstream size-limit change is rejected | Ship approach C (skills-only root). A trimmed image does not help, because `kagent-tools` + `kubectl` alone is 136 MB | +| Gateway MITM breaks apiserver TLS or upgrades | E2E verifies it. Document `exec`/streaming as best effort | +| ~650 MB volume per Actor increases cold-start time | Worker image cache is shared with workload images. A future slim image could drop `cilium`/`argo` | +| Multi-arch index resolution by Substrate image volumes | Verify in e2e on arm64. Fall back to pinning the per-arch digest in docs | +| `mcp.json` env overrides the Harness `PATH` | The plugin `PATH` includes standard system dirs, and only the stdio server sees it | diff --git a/go.mod b/go.mod index df04b46f..ce3c1407 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/kagent-dev/tools -go 1.27.1 +go 1.27.3 require ( github.com/google/jsonschema-go v0.4.3 @@ -26,6 +26,7 @@ require ( k8s.io/apiextensions-apiserver v0.37.0 k8s.io/apimachinery v0.37.0 k8s.io/client-go v0.37.0 + sigs.k8s.io/yaml v1.6.0 ) require ( @@ -198,5 +199,4 @@ require ( sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 0569689d..908a658b 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -8,8 +8,17 @@ import ( "go.opentelemetry.io/otel/trace" ) +// UseStderrEnv names the environment variable that routes console output to +// stderr, keeping stdout free for the MCP stdio protocol. +const UseStderrEnv = "KAGENT_USE_STDERR" + var globalLogger *slog.Logger +// UseStderr reports whether console output must go to stderr. +func UseStderr() bool { + return os.Getenv(UseStderrEnv) == "true" +} + // Init initializes the global logger // If useStderr is true, logs will be written to stderr (for stdio mode) // If useStderr is false, logs will be written to stdout (for HTTP mode) @@ -36,8 +45,7 @@ func Init(useStderr bool) { // InitWithEnv initializes the logger using environment variables // This is a convenience function that defaults to stdout unless KAGENT_USE_STDERR is set func InitWithEnv() { - useStderr := os.Getenv("KAGENT_USE_STDERR") == "true" - Init(useStderr) + Init(UseStderr()) } func Get() *slog.Logger { diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index f8196962..e4ca6d63 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -125,3 +125,10 @@ func TestInit(t *testing.T) { func TestSync(t *testing.T) { assert.NotPanics(t, Sync) } + +func TestUseStderr(t *testing.T) { + t.Setenv(UseStderrEnv, "true") + require.True(t, UseStderr()) + t.Setenv(UseStderrEnv, "false") + require.False(t, UseStderr()) +} diff --git a/internal/telemetry/console_writer_test.go b/internal/telemetry/console_writer_test.go new file mode 100644 index 00000000..3e5a0bc8 --- /dev/null +++ b/internal/telemetry/console_writer_test.go @@ -0,0 +1,18 @@ +package telemetry + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConsoleWriterUsesStderrWhenRequested(t *testing.T) { + t.Setenv("KAGENT_USE_STDERR", "true") + require.Same(t, os.Stderr, consoleWriter()) +} + +func TestConsoleWriterDefaultsToStdout(t *testing.T) { + t.Setenv("KAGENT_USE_STDERR", "") + require.Same(t, os.Stdout, consoleWriter()) +} diff --git a/internal/telemetry/tracing.go b/internal/telemetry/tracing.go index 6b6f7208..fca4c1ac 100644 --- a/internal/telemetry/tracing.go +++ b/internal/telemetry/tracing.go @@ -144,7 +144,7 @@ func createExporter(ctx context.Context, cfg *Telemetry) (sdktrace.SpanExporter, } if cfg.Endpoint == "" { - return stdouttrace.New(stdouttrace.WithPrettyPrint()) + return stdouttrace.New(stdouttrace.WithWriter(consoleWriter()), stdouttrace.WithPrettyPrint()) } // Determine protocol @@ -280,3 +280,12 @@ func parseHeaders(headers string) map[string]string { } return headerMap } + +// consoleWriter keeps the fallback span exporter off stdout when stdout +// carries the MCP stdio protocol. +func consoleWriter() *os.File { + if logger.UseStderr() { + return os.Stderr + } + return os.Stdout +} diff --git a/plugin/config/kubeconfig b/plugin/config/kubeconfig new file mode 100644 index 00000000..78d9f7d1 --- /dev/null +++ b/plugin/config/kubeconfig @@ -0,0 +1,20 @@ +# Used when kagent-tools runs as a kagent Agent Plugin. Requests go through the +# kagent/Substrate egress gateway, which replaces the placeholder bearer token +# with a ServiceAccount token from an EgressPolicy. No credentials live here. +apiVersion: v1 +kind: Config +clusters: + - name: in-cluster + cluster: + server: https://kubernetes.default.svc + certificate-authority: /run/kagent/egress/trust-bundle.pem +users: + - name: kagent-egress + user: + token: kagent-credential-injected +contexts: + - name: default + context: + cluster: in-cluster + user: kagent-egress +current-context: default diff --git a/plugin/mcp.json b/plugin/mcp.json new file mode 100644 index 00000000..500c4e3e --- /dev/null +++ b/plugin/mcp.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "kagent-tools": { + "type": "stdio", + "command": "./bin/kagent-tools", + "args": ["--stdio"], + "env": { + "PATH": "${PLUGIN_ROOT}/bin:/usr/local/bin:/usr/bin:/bin", + "KAGENT_TOOLS_DEFAULT_KUBECONFIG": "${PLUGIN_ROOT}/config/kubeconfig", + "KAGENT_USE_STDERR": "true", + "HOME": "${PLUGIN_DATA}", + "XDG_CACHE_HOME": "${PLUGIN_DATA}/cache", + "XDG_CONFIG_HOME": "${PLUGIN_DATA}/config", + "XDG_DATA_HOME": "${PLUGIN_DATA}/data" + } + } + } +} diff --git a/plugin/plugin.json.tmpl b/plugin/plugin.json.tmpl new file mode 100644 index 00000000..ac542a78 --- /dev/null +++ b/plugin/plugin.json.tmpl @@ -0,0 +1,10 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "kagent-tools", + "version": "__VERSION__", + "description": "Kubernetes, Helm, Istio, Argo Rollouts, Cilium, Kubescape and Prometheus tools with operational skills for kagent agents.", + "homepage": "https://github.com/kagent-dev/tools", + "repository": "https://github.com/kagent-dev/tools", + "license": "Apache-2.0", + "keywords": ["kubernetes", "helm", "istio", "argo-rollouts", "cilium", "kubescape", "prometheus", "mcp"] +} diff --git a/plugin/skills-plugin.json.tmpl b/plugin/skills-plugin.json.tmpl new file mode 100644 index 00000000..304d18d5 --- /dev/null +++ b/plugin/skills-plugin.json.tmpl @@ -0,0 +1,9 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "kagent-tools-skills", + "version": "__VERSION__", + "description": "Operational skills for the kagent-tools MCP server (use with a kagent-tools RemoteMCPServer).", + "homepage": "https://github.com/kagent-dev/tools", + "repository": "https://github.com/kagent-dev/tools", + "license": "Apache-2.0" +} diff --git a/plugin/skills/argo-rollouts-progressive-delivery/SKILL.md b/plugin/skills/argo-rollouts-progressive-delivery/SKILL.md new file mode 100644 index 00000000..523a073f --- /dev/null +++ b/plugin/skills/argo-rollouts-progressive-delivery/SKILL.md @@ -0,0 +1,19 @@ +--- +name: argo-rollouts-progressive-delivery +description: Operate Argo Rollouts canary and blue-green releases (status, promote, pause, change image) with the kagent-tools argo_* MCP tools. Use when a Rollout is progressing, paused, degraded, or needs a new image. +--- +# Argo Rollouts progressive delivery + +## Inspect (read-only) +1. `argo_verify_argo_rollouts_controller_install` and `argo_verify_kubectl_plugin_install` once per cluster. +2. `argo_rollouts_list` in the namespace for phase, step, and weight. +3. If a Rollout is `Degraded`, describe it with `k8s_describe_resource` (`resource_type: rollout`) and check AnalysisRun results with `k8s_get_resources` (`resource_type: analysisrun`). + +## Act (mutating; confirm with the user first) +- Start a release: `argo_set_rollout_image` with the container name and the full image reference. +- Hold: `argo_pause_rollout`. +- Advance one step or fully promote: `argo_promote_rollout`. State which applies before running it. +- Gateway API traffic routing: `argo_verify_gateway_plugin` before the first release. + +## Verify +After each action, run `argo_rollouts_list` again. Report the step and weight, and stop if analysis fails. diff --git a/plugin/skills/cilium-network-debugging/SKILL.md b/plugin/skills/cilium-network-debugging/SKILL.md new file mode 100644 index 00000000..ab513924 --- /dev/null +++ b/plugin/skills/cilium-network-debugging/SKILL.md @@ -0,0 +1,24 @@ +--- +name: cilium-network-debugging +description: Diagnose Cilium CNI networking (agent health, endpoint and identity state, network policy drops, services, BGP, cluster mesh) with the kagent-tools cilium_* MCP tools. Use when pods cannot reach each other or services, or a policy blocks traffic unexpectedly. +--- +# Cilium network debugging + +## 1. Health (read-only) +1. `cilium_status_and_version` for an overall status and version. +2. `cilium_get_daemon_status` on the node that hosts the affected pod. +3. `cilium_list_cluster_nodes` to confirm all nodes are known. + +## 2. Endpoint and identity +1. `cilium_get_endpoints_list`, then `cilium_get_endpoint_details` for the source and destination pods. Check that they are in the `ready` state. +2. `cilium_get_identity_details` for both identities. Check that their labels match what the policy selects. +3. `cilium_display_selectors` and `cilium_display_policy_node_information` to see which policies apply. +4. `cilium_validate_cilium_network_policies` to catch invalid policies. + +## 3. Services and routing +- `cilium_list_services` / `cilium_get_service_information` for ClusterIP and backend mapping. +- `cilium_list_bgp_peers` / `cilium_list_bgp_routes` for BGP setups. +- `cilium_show_cluster_mesh_status` for multi-cluster setups. + +## 4. Change (mutating; confirm first) +Prefer fixing the CiliumNetworkPolicy manifest through `k8s_apply_manifest` over imperative cilium state changes. Never run `cilium_uninstall_cilium` or `cilium_flush_ipsec_state` without explicit, specific confirmation. diff --git a/plugin/skills/helm-release-management/SKILL.md b/plugin/skills/helm-release-management/SKILL.md new file mode 100644 index 00000000..e7824028 --- /dev/null +++ b/plugin/skills/helm-release-management/SKILL.md @@ -0,0 +1,19 @@ +--- +name: helm-release-management +description: Inspect, upgrade, roll forward and uninstall Helm releases with the kagent-tools helm_* MCP tools. Use for questions about installed charts, release status, values, failed upgrades, or adding chart repositories. +--- +# Helm release management + +## Inspect (read-only) +1. `helm_list_releases` (namespace or all namespaces) to find the release and its STATUS (`deployed`, `failed`, `pending-*`). +2. `helm_get_release` for values, manifest, notes, or hooks of a single release. +3. To check what the release created, use `k8s_get_resources` in the release namespace. + +## Change (mutating; confirm with the user first) +- Add or refresh chart sources: `helm_repo_add`, then `helm_repo_update`. +- Upgrade or install: `helm_upgrade` with the chart, version, and only the values that change. Restate the chart version and the value diff before running it. +- A release stuck in `pending-upgrade`/`pending-install` usually needs the previous operation to finish or be rolled back. Explain this before acting. +- Remove: `helm_uninstall`, only after explicit confirmation that names the release and namespace. + +## Verify +After any change, run `helm_list_releases` and `k8s_get_resources` for the workload pods. Report the new revision and pod health. diff --git a/plugin/skills/istio-traffic-debugging/SKILL.md b/plugin/skills/istio-traffic-debugging/SKILL.md new file mode 100644 index 00000000..88b40a74 --- /dev/null +++ b/plugin/skills/istio-traffic-debugging/SKILL.md @@ -0,0 +1,27 @@ +--- +name: istio-traffic-debugging +description: Debug Istio service-mesh traffic (503s, mTLS failures, routing not applied, sidecar or ambient/waypoint issues) using the kagent-tools istio_* MCP tools. Use when requests between mesh services fail or routing rules do not take effect. +--- +# Istio traffic debugging + +## 1. Mesh health (read-only) +1. `istio_version` for control-plane and data-plane versions. +2. `istio_proxy_status` to check that every proxy is `SYNCED`. A `STALE` or `NOT SENT` proxy points to a control-plane push problem. +3. `istio_analyze_cluster_configuration` for the namespace. Fix reported errors first. + +## 2. Follow one request +1. Identify the source pod and destination service. +2. `istio_proxy_config` on the source pod for `routes`, `clusters`, and `endpoints`. Confirm the destination host and subset exist and endpoints are `HEALTHY`. +3. For ambient mode, use `istio_ztunnel_config`, `istio_list_waypoints`, and `istio_waypoint_status` to confirm the waypoint is attached and programmed. +4. Read the VirtualService, DestinationRule, and AuthorizationPolicy objects with `k8s_get_resource_yaml`. + +## 3. Common causes +| Symptom | Likely cause | +|---|---| +| 503 `UF`/`URX` | No healthy endpoints, or a DestinationRule subset with no matching pod labels | +| 503 `NR` | No route: a VirtualService host or gateway mismatch | +| `RBAC: access denied` | AuthorizationPolicy | +| TLS errors | PeerAuthentication or DestinationRule `tls.mode` mismatch | + +## 4. Change (mutating; confirm first) +Use `istio_apply_waypoint`/`istio_delete_waypoint`, or `k8s_apply_manifest` for Istio CRs. Show the manifest first. Re-run step 2 to verify. diff --git a/plugin/skills/k8s-troubleshooting/SKILL.md b/plugin/skills/k8s-troubleshooting/SKILL.md new file mode 100644 index 00000000..216d1f01 --- /dev/null +++ b/plugin/skills/k8s-troubleshooting/SKILL.md @@ -0,0 +1,29 @@ +--- +name: k8s-troubleshooting +description: Diagnose unhealthy Kubernetes workloads (CrashLoopBackOff, Pending, ImagePullBackOff, OOMKilled, failing readiness, unreachable Services) using the kagent-tools k8s_* MCP tools. Use when a pod, deployment or service is not working as expected. +--- +# Kubernetes troubleshooting + +Work from symptoms to cause. Gather facts before proposing changes. + +## 1. Locate the failing object (read-only) +1. `k8s_get_resources` with `resource_type: pods` and the namespace (`all_namespaces: true` if unknown). Note STATUS, RESTARTS, and READY. +2. `k8s_describe_resource` on the failing pod. Read the **Events** section and the container `State`/`Last State`. +3. `k8s_get_events` for the namespace to find scheduling, pull, or probe failures. + +## 2. Classify +| Symptom | Next step | +|---|---| +| `CrashLoopBackOff`, `Error` | `k8s_get_pod_logs` (also with `previous: true` if supported) for the crashing container | +| `OOMKilled` | Compare limits in `k8s_get_resource_yaml` with usage. Recommend new limits | +| `Pending` | Describe the pod: insufficient CPU/memory, taints, node selectors, or unbound PVCs | +| `ImagePullBackOff` | Check the image name/tag and `imagePullSecrets` in `k8s_get_resource_yaml` | +| Readiness failing | Compare the probe path/port in YAML with the container port. Check logs | +| Service unreachable | `k8s_get_resources` for `endpoints`/`endpointslices`, compare Service selector with pod labels, then `k8s_check_service_connectivity` | + +## 3. Remediate (mutating; confirm with the user first) +Only after the user agrees: `k8s_patch_resource`, `k8s_scale`, `k8s_rollout` (restart/undo), or `k8s_apply_manifest`. Show the exact change before applying it. Afterwards, re-run step 1 and report the result. + +## Notes +- Mutating tools are absent when the server runs read-only. In that case, provide the patch as YAML for the user to apply. +- Do not use `k8s_delete_resource` to "restart" pods; use `k8s_rollout` restart. diff --git a/plugin/skills/kubescape-security-posture/SKILL.md b/plugin/skills/kubescape-security-posture/SKILL.md new file mode 100644 index 00000000..40181d60 --- /dev/null +++ b/plugin/skills/kubescape-security-posture/SKILL.md @@ -0,0 +1,21 @@ +--- +name: kubescape-security-posture +description: Review cluster security posture with Kubescape results (configuration scans, image vulnerabilities, application profiles, network neighborhoods) using the kagent-tools kubescape_* MCP tools. Use for security reviews, CVE questions, or hardening recommendations. +--- +# Kubescape security posture + +## 1. Preconditions +`kubescape_check_health` must report the operator and storage as healthy. Otherwise, explain that the Kubescape operator must be installed first. + +## 2. Configuration risks +1. `kubescape_list_configuration_scans` in the namespace (or cluster-wide). +2. `kubescape_get_configuration_scan` for a workload. Group failed controls by severity and give the YAML fix for each. + +## 3. Vulnerabilities +1. `kubescape_list_vulnerability_manifests` / `kubescape_list_vulnerabilities`, filtered to Critical and High first. +2. `kubescape_get_vulnerability_details` for a CVE: affected package, fixed version, and which workloads use the image. + +## 4. Runtime behaviour +`kubescape_list_application_profiles` / `kubescape_get_application_profile` and `kubescape_list_network_neighborhoods` / `kubescape_get_network_neighborhood` show the observed syscalls, processes, and connections. Use them to propose least-privilege seccomp or NetworkPolicies. + +Report findings as a prioritised table with severity, resource, issue, and fix. This skill is read-only; apply fixes only through other skills after confirmation. diff --git a/plugin/skills/prometheus-observability/SKILL.md b/plugin/skills/prometheus-observability/SKILL.md new file mode 100644 index 00000000..2115ca8b --- /dev/null +++ b/plugin/skills/prometheus-observability/SKILL.md @@ -0,0 +1,19 @@ +--- +name: prometheus-observability +description: Answer questions about cluster and application metrics (error rates, latency, saturation, scrape health) with the kagent-tools prometheus_* MCP tools. Use when investigating performance, alerts, or whether a target is scraped. Requires PROMETHEUS_URL to be configured. +--- +# Prometheus observability + +## 1. Check connectivity +`prometheus_targets_tool` lists scrape targets. If it fails, tell the user that `PROMETHEUS_URL` (and an EgressPolicy for it when running inside a kagent agent) must be configured. + +## 2. Discover before querying +`prometheus_label_names_tool` shows the available labels, so queries use real label names. + +## 3. Query +- Current value: `prometheus_query_tool`. Example: `sum(rate(http_requests_total{namespace="shop",code=~"5.."}[5m])) by (service)`. +- Trend: `prometheus_query_range_tool` with a range and step (for example, 1h with a 60s step). +- Draft complex PromQL with `prometheus_promql_tool`, then run it. + +## 4. Interpret +Use RED for services (rate, errors, duration) and USE for resources (utilisation, saturation, errors). Always state the query you ran and the time window. Correlate spikes with `k8s_get_events` and rollouts. diff --git a/scripts/verify-plugin-image.sh b/scripts/verify-plugin-image.sh new file mode 100755 index 00000000..b75a3efc --- /dev/null +++ b/scripts/verify-plugin-image.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Verifies that a built kagent-tools image is a valid kagent Agent Plugin. +set -euo pipefail + +image="${1:?usage: $0 }" +workdir="$(mktemp -d)" +container="" +cleanup() { + [[ -n "$container" ]] && docker rm -f "$container" >/dev/null 2>&1 || true + rm -rf "$workdir" +} +trap cleanup EXIT + +container="$(docker create "$image")" +docker export "$container" | tar -x -C "$workdir" + +KAGENT_TOOLS_IMAGE_ROOT="$workdir" go test ./cmd -run TestPluginImageLayout -count=1 -v + +echo "--- legacy entrypoints" +docker run --rm --entrypoint /tool-server "$image" --version +docker run --rm --entrypoint /bin/kubectl "$image" version --client +docker run --rm --entrypoint /bin/helm "$image" version --short + +echo "--- stdio smoke test" +# Keep stdin open briefly: the server exits on EOF before answering. +response="$({ printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'; sleep 5; } \ + | docker run -i --rm --entrypoint /opt/kagent-tools/bin/kagent-tools "$image" --stdio 2>/dev/null)" + +for tool in k8s_get_resources helm_list_releases istio_proxy_status argo_rollouts_list cilium_status_and_version; do + grep -q "\"name\":\"$tool\"" <<<"$response" || { echo "missing tool $tool in stdio tools/list"; exit 1; } +done +if grep -qv '^{"jsonrpc"' <<<"$response"; then + echo "non JSON-RPC output on stdout:"; grep -v '^{"jsonrpc"' <<<"$response" | head -5; exit 1 +fi +echo "plugin image OK: $image"