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..a03e3f89 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,18 @@ 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, that +every tool a skill names is registered, and that every `` `argument: value` `` on a skill line is an +input of a tool named on that line. `cmd/plugin_docs_test.go` checks the `AgentTemplate` examples +in `docs/kagent-skills.md` against kagent's rules (`modelConfig`, at most 50 bound tools, and a +`tools:` list that covers the selected skills). Skills must only name tools that work with the +chart defaults (no tool that needs an unconfigured LLM key). 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..a9ce921a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -80,30 +80,58 @@ 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 +# Only the small files and links are assembled here, on their own base so CLI +# downloads do not invalidate this stage. Binaries are copied in the final stage. +ARG BASE_IMAGE_REGISTRY=cgr.dev +FROM $BASE_IMAGE_REGISTRY/chainguard/wolfi-base:latest AS plugin +ARG VERSION=dev +COPY plugin /src/plugin +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; \ + 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 - -# 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 +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) +# Three content layers, ordered from least to most frequently changed: +# 1. all CLIs (~500 MiB, change only on a version bump) +# 2. plugin manifests, skills and links (a few KiB) +# 3. the Go server (changes on every commit, ~70 MiB) +# so a Go or skill change re-ships only the small layers. +COPY --from=tools --chown=65532:65532 --chmod=0755 \ + /downloads/kubectl /downloads/helm /downloads/istioctl /downloads/kubectl-argo-rollouts /downloads/cilium \ + /opt/kagent-tools/bin/ +COPY --from=plugin /out/ / +COPY --from=builder --chown=65532:65532 --chmod=0755 /workspace/tool-server /opt/kagent-tools/bin/kagent-tools 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..d80732ca 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,14 @@ 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-skills` to get the operational skills, and bind the +tools through a `RemoteMCPServer`. The full root, `opt/kagent-tools` (MCP tools, all CLIs and +skills in one bundle), is over kagent's 100 MiB package limit and needs a future kagent release. +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..952a35fb --- /dev/null +++ b/cmd/plugin_bundle_test.go @@ -0,0 +1,201 @@ +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_]+)`") + argumentReference = regexp.MustCompile("`([a-z][a-z_]*): [^`]+`") +) + +// llmBackedTools need an LLM API key that neither the Helm chart nor the plugin configures, +// so skills must not depend on them. +var llmBackedTools = map[string]bool{ + "k8s_generate_resource": true, + "prometheus_promql_tool": true, +} + +type toolInputSchema struct { + Properties map[string]json.RawMessage `json:"properties"` +} + +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")) +} + +// registeredTools returns the input properties of every tool the server registers, keyed by tool name. +func registeredTools(t *testing.T) map[string]toolInputSchema { + 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() }) + tools := map[string]toolInputSchema{} + for tool, err := range session.Tools(ctx, nil) { + require.NoError(t, err) + rawSchema, err := json.Marshal(tool.InputSchema) + require.NoError(t, err) + var schema toolInputSchema + require.NoError(t, json.Unmarshal(rawSchema, &schema), tool.Name) + tools[tool.Name] = schema + } + return tools +} + +func readSkill(t *testing.T, skillName string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(pluginSourceDir, "skills", skillName, "SKILL.md")) + require.NoError(t, err, skillName) + return string(raw) +} + +func referencedTools(content string) []string { + var tools []string + for _, reference := range toolReference.FindAllStringSubmatch(content, -1) { + tools = append(tools, reference[1]) + } + return tools +} + +func TestPluginSkillsAreValid(t *testing.T) { + skillDirs, err := os.ReadDir(filepath.Join(pluginSourceDir, "skills")) + require.NoError(t, err) + require.NotEmpty(t, skillDirs) + knownTools := registeredTools(t) + + for _, skillDir := range skillDirs { + require.True(t, skillDir.IsDir(), skillDir.Name()) + content := readSkill(t, skillDir.Name()) + 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 := referencedTools(content) + require.NotEmpty(t, references, "%s: skill must reference at least one MCP tool", skillDir.Name()) + for _, toolName := range references { + require.Containsf(t, knownTools, toolName, "%s references unknown tool %q", skillDir.Name(), toolName) + require.Falsef(t, llmBackedTools[toolName], "%s references %q, which needs an unconfigured LLM key", skillDir.Name(), toolName) + } + requireArgumentsExist(t, skillDir.Name(), content, knownTools) + } +} + +// requireArgumentsExist checks that every `argument: value` a skill line names is an input +// property of a tool referenced on the same line, so playbooks cannot drift from tool schemas. +func requireArgumentsExist(t *testing.T, skillName, content string, knownTools map[string]toolInputSchema) { + t.Helper() + for lineNumber, line := range strings.Split(content, "\n") { + arguments := argumentReference.FindAllStringSubmatch(line, -1) + if len(arguments) == 0 { + continue + } + lineTools := referencedTools(line) + require.NotEmptyf(t, lineTools, "%s:%d names arguments without a tool on the same line", skillName, lineNumber+1) + for _, argument := range arguments { + found := false + for _, toolName := range lineTools { + if _, ok := knownTools[toolName].Properties[argument[1]]; ok { + found = true + break + } + } + require.Truef(t, found, "%s:%d: no tool in %v accepts argument %q", skillName, lineNumber+1, lineTools, argument[1]) + } + } +} diff --git a/cmd/plugin_docs_test.go b/cmd/plugin_docs_test.go new file mode 100644 index 00000000..635821e9 --- /dev/null +++ b/cmd/plugin_docs_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "os" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" +) + +const ( + skillsGuidePath = "../docs/kagent-skills.md" + skillsOnlyRoot = "opt/kagent-tools-skills" + // kagent go/api/v1alpha3 MCPToolBinding.Tools has MaxItems=50. + maxToolsPerBinding = 50 +) + +var yamlCodeBlock = regexp.MustCompile("(?s)```yaml\n(.*?)```") + +// Mirrors the subset of kagent go/api/v1alpha3 AgentTemplate the guide uses. +type agentTemplateExample struct { + Kind string `json:"kind"` + Spec struct { + ModelConfig *struct { + Name string `json:"name"` + } `json:"modelConfig"` + Tools []struct { + MCP *struct { + Server struct { + APIGroup string `json:"apiGroup"` + Kind string `json:"kind"` + Name string `json:"name"` + } `json:"server"` + Tools []string `json:"tools"` + } `json:"mcp"` + } `json:"tools"` + Plugins []struct { + Source struct { + OCI string `json:"oci"` + Path string `json:"path"` + } `json:"source"` + Skills []string `json:"skills"` + } `json:"plugins"` + } `json:"spec"` +} + +func agentTemplateExamples(t *testing.T) []agentTemplateExample { + t.Helper() + raw, err := os.ReadFile(skillsGuidePath) + require.NoError(t, err) + var templates []agentTemplateExample + for _, block := range yamlCodeBlock.FindAllStringSubmatch(string(raw), -1) { + for _, document := range strings.Split(block[1], "\n---\n") { + var template agentTemplateExample + require.NoError(t, yaml.Unmarshal([]byte(document), &template)) + if template.Kind == "AgentTemplate" { + templates = append(templates, template) + } + } + } + return templates +} + +func TestSkillsGuideAgentTemplatesAreValid(t *testing.T) { + templates := agentTemplateExamples(t) + require.Len(t, templates, 2, "expected one full-root and one skills-only example") + knownTools := registeredTools(t) + + for index, template := range templates { + require.NotNilf(t, template.Spec.ModelConfig, "example %d: managed harnesses require modelConfig", index) + require.NotEmpty(t, template.Spec.ModelConfig.Name, "example %d", index) + require.NotEmpty(t, template.Spec.Plugins, "example %d", index) + + boundTools := map[string]bool{} + for _, binding := range template.Spec.Tools { + require.NotNil(t, binding.MCP, "example %d", index) + require.Equal(t, "RemoteMCPServer", binding.MCP.Server.Kind, "example %d", index) + require.Empty(t, binding.MCP.Server.APIGroup, "example %d: kagent rejects an apiGroup on RemoteMCPServer refs", index) + require.LessOrEqual(t, len(binding.MCP.Tools), maxToolsPerBinding, "example %d", index) + for _, toolName := range binding.MCP.Tools { + require.Containsf(t, knownTools, toolName, "example %d binds unknown tool %q", index, toolName) + boundTools[toolName] = true + } + } + + for _, plugin := range template.Spec.Plugins { + require.Contains(t, plugin.Source.OCI, "@sha256:", "example %d: pin the image by digest", index) + if plugin.Source.Path != skillsOnlyRoot || len(boundTools) == 0 { + continue + } + for _, skillName := range plugin.Skills { + for _, toolName := range referencedTools(readSkill(t, skillName)) { + require.Truef(t, boundTools[toolName], "example %d: skill %s uses %s, which the tools list omits", index, skillName, toolName) + } + } + } + } +} 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..a71c577e --- /dev/null +++ b/docs/kagent-skills.md @@ -0,0 +1,251 @@ +# 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 to get a set of operational skills. Today, mount the skills-only root and +bind the tools through a `RemoteMCPServer`. The full root, which also ships the MCP server and all +CLIs in one bundle, needs a future kagent release (see [Requirements](#requirements)). + +## What you get + +The image contains two plugin roots. Select **one** of them per agent. + +| Root (`source.path`) | Contents | +|---|---| +| `opt/kagent-tools-skills` | `plugin.json` and `skills/` only (use with a `RemoteMCPServer`). Works on current kagent 1.x. | +| `opt/kagent-tools` | `plugin.json`, `mcp.json` (stdio MCP server), `bin/` (`kagent-tools`, `kubectl`, `helm`, `istioctl`, `kubectl-argo-rollouts`, `cilium`), `config/kubeconfig`, `skills/`. Needs a future kagent release. | + +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`) and a `ModelConfig` (the examples use + `default-model-config`). +- For the full root: 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 about 550 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: + ``` + +## Skills-only mode + +This works on current kagent 1.x releases and with every harness. Deploy the server with the Helm +chart as usual, reference it as a `RemoteMCPServer`, and mount 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. + modelConfig: + name: default-model-config + tools: + - mcp: + server: + kind: RemoteMCPServer + name: kagent-tools + # Only the tools the selected skills use. kagent accepts at most 50 per binding, + # and OpenAI and Azure OpenAI reject more than 128 tools per request. + tools: + - k8s_get_resources + - k8s_describe_resource + - k8s_get_resource_yaml + - k8s_get_events + - k8s_get_pod_logs + - k8s_check_service_connectivity + - k8s_apply_manifest + - k8s_patch_resource + - k8s_delete_resource + - k8s_rollout + - k8s_scale + plugins: + - source: + oci: ghcr.io/kagent-dev/kagent/tools@sha256: + path: opt/kagent-tools-skills + skills: + - k8s-troubleshooting +``` + +The server's `PROMETHEUS_URL` and other settings come from the Helm values; the +[Harness env](#tuning-via-harness-env) table applies only to the bundled server. + +> **Never select both roots in one agent.** They contain the same skill names, and kagent +> rejects duplicate skills. + +## Full root (needs a future kagent release) + +The sections below apply only to `opt/kagent-tools`. It is about 550 MiB, over kagent's +100 MiB package limit, so current kagent releases reject it with +`artifact exceeds 104857600 bytes`. Until a kagent release lifts that limit for +platform-mounted OCI packages, use [skills-only mode](#skills-only-mode). + +### 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. + modelConfig: + name: default-model-config + 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. + +## 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/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/pkg/argo/argo.go b/pkg/argo/argo.go index b1044c48..6e19e4f2 100644 --- a/pkg/argo/argo.go +++ b/pkg/argo/argo.go @@ -146,7 +146,7 @@ func handlePauseRollout(ctx context.Context, request *sdkmcp.CallToolRequest, in type setRolloutImageInput struct { RolloutName string `json:"rollout_name" jsonschema:"The name of the rollout to set the image for"` - ContainerImage string `json:"container_image" jsonschema:"The container image to set for the rollout"` + ContainerImage string `json:"container_image" jsonschema:"Container and image as =, for example app=nginx:1.27 (passed to kubectl argo rollouts set image)"` Namespace string `json:"namespace" jsonschema:"The namespace of the rollout"` } diff --git a/pkg/prometheus/prometheus.go b/pkg/prometheus/prometheus.go index 8d01bdb7..f0254202 100644 --- a/pkg/prometheus/prometheus.go +++ b/pkg/prometheus/prometheus.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "os" "time" "github.com/kagent-dev/tools/internal/errors" @@ -43,15 +44,29 @@ func prettyJSONBody(body []byte) string { return indented.String() } +const ( + prometheusURLEnv = "PROMETHEUS_URL" + fallbackPrometheusURL = "http://localhost:9090" +) + +// defaultPrometheusURL is used when a tool call has no prometheus_url. The Helm +// chart and kagent Harness env set PROMETHEUS_URL; localhost is the last resort. +func defaultPrometheusURL() string { + if configuredURL := os.Getenv(prometheusURLEnv); configuredURL != "" { + return configuredURL + } + return fallbackPrometheusURL +} + type prometheusQueryInput struct { Query string `json:"query" jsonschema:"PromQL query to execute"` - PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: http://localhost:9090)"` + PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: PROMETHEUS_URL, else http://localhost:9090)"` } func handlePrometheusQueryTool(ctx context.Context, request *sdkmcp.CallToolRequest, in prometheusQueryInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { prometheusURL := in.PrometheusURL if prometheusURL == "" { - prometheusURL = "http://localhost:9090" + prometheusURL = defaultPrometheusURL() } query := in.Query @@ -123,13 +138,13 @@ type prometheusRangeQueryInput struct { Start string `json:"start" jsonschema:"Start time (Unix timestamp or relative time)"` End string `json:"end" jsonschema:"End time (Unix timestamp or relative time)"` Step string `json:"step" jsonschema:"Query resolution step (default: 15s)"` - PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: http://localhost:9090)"` + PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: PROMETHEUS_URL, else http://localhost:9090)"` } func handlePrometheusRangeQueryTool(ctx context.Context, request *sdkmcp.CallToolRequest, in prometheusRangeQueryInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { prometheusURL := in.PrometheusURL if prometheusURL == "" { - prometheusURL = "http://localhost:9090" + prometheusURL = defaultPrometheusURL() } query := in.Query start := in.Start @@ -214,13 +229,13 @@ func handlePrometheusRangeQueryTool(ctx context.Context, request *sdkmcp.CallToo } type prometheusLabelsInput struct { - PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: http://localhost:9090)"` + PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: PROMETHEUS_URL, else http://localhost:9090)"` } func handlePrometheusLabelsQueryTool(ctx context.Context, request *sdkmcp.CallToolRequest, in prometheusLabelsInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { prometheusURL := in.PrometheusURL if prometheusURL == "" { - prometheusURL = "http://localhost:9090" + prometheusURL = defaultPrometheusURL() } // Validate prometheus URL @@ -272,13 +287,13 @@ func handlePrometheusLabelsQueryTool(ctx context.Context, request *sdkmcp.CallTo } type prometheusTargetsInput struct { - PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: http://localhost:9090)"` + PrometheusURL string `json:"prometheus_url" jsonschema:"Prometheus server URL (default: PROMETHEUS_URL, else http://localhost:9090)"` } func handlePrometheusTargetsQueryTool(ctx context.Context, request *sdkmcp.CallToolRequest, in prometheusTargetsInput) (*sdkmcp.CallToolResult, mcp.TextOutput, error) { prometheusURL := in.PrometheusURL if prometheusURL == "" { - prometheusURL = "http://localhost:9090" + prometheusURL = defaultPrometheusURL() } // Validate prometheus URL diff --git a/pkg/prometheus/prometheus_test.go b/pkg/prometheus/prometheus_test.go index 518a84a8..5447ab76 100644 --- a/pkg/prometheus/prometheus_test.go +++ b/pkg/prometheus/prometheus_test.go @@ -9,6 +9,7 @@ import ( sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestRegisterTools(t *testing.T) { @@ -558,3 +559,70 @@ func TestPrometheusURLEncoding(t *testing.T) { assert.Contains(t, content, "success") }) } + +// recordingRoundTripper records the request URL and returns an empty success. +type recordingRoundTripper struct { + requestURL string +} + +func (recorder *recordingRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + recorder.requestURL = request.URL.String() + return createMockResponse(200, `{"status":"success","data":[]}`), nil +} + +func TestPrometheusURLDefault(t *testing.T) { + const configuredURL = "http://prometheus.monitoring:9090" + testCases := []struct { + name string + callTool func(ctx context.Context) error + expectedURL string + }{ + { + name: "query", + callTool: func(ctx context.Context) error { + _, _, err := handlePrometheusQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusQueryInput{Query: "up"}) + return err + }, + expectedURL: configuredURL + "/api/v1/query", + }, + { + name: "range query", + callTool: func(ctx context.Context) error { + _, _, err := handlePrometheusRangeQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusRangeQueryInput{Query: "up"}) + return err + }, + expectedURL: configuredURL + "/api/v1/query_range", + }, + { + name: "labels", + callTool: func(ctx context.Context) error { + _, _, err := handlePrometheusLabelsQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusLabelsInput{}) + return err + }, + expectedURL: configuredURL + "/api/v1/labels", + }, + { + name: "targets", + callTool: func(ctx context.Context) error { + _, _, err := handlePrometheusTargetsQueryTool(ctx, &sdkmcp.CallToolRequest{}, prometheusTargetsInput{}) + return err + }, + expectedURL: configuredURL + "/api/v1/targets", + }, + } + for _, testCase := range testCases { + t.Run(testCase.name+" uses PROMETHEUS_URL when the call has no prometheus_url", func(t *testing.T) { + t.Setenv(prometheusURLEnv, configuredURL) + recorder := &recordingRoundTripper{} + ctx := contextWithMockClient(&http.Client{Transport: recorder}) + + require.NoError(t, testCase.callTool(ctx)) + assert.True(t, strings.HasPrefix(recorder.requestURL, testCase.expectedURL), recorder.requestURL) + }) + } + + t.Run("localhost when PROMETHEUS_URL is unset", func(t *testing.T) { + t.Setenv(prometheusURLEnv, "") + assert.Equal(t, fallbackPrometheusURL, defaultPrometheusURL()) + }) +} 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..6b7c139d --- /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 `rollout_name` and `container_image: =`, for example `app=registry.example.com/app:1.4.2`. Read the container name from `k8s_get_resource_yaml` first. +- 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..acd3d025 --- /dev/null +++ b/plugin/skills/cilium-network-debugging/SKILL.md @@ -0,0 +1,28 @@ +--- +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 + +Most `cilium_*` tools run against the Cilium agent on one node and need its name as `node_name`; without it they fail. Only `cilium_status_and_version`, the BGP tools, and `cilium_show_cluster_mesh_status` are cluster-wide. + +## 1. Health (read-only) +1. `cilium_status_and_version` for an overall status and version. +2. Find the node that hosts the affected pod with `k8s_get_resources` (`resource_type: pods`, `output: wide`); its NODE column is the `node_name` for the steps below. +3. `cilium_get_daemon_status` on that node. +4. `cilium_list_cluster_nodes` to confirm all nodes are known. + +## 2. Endpoint and identity +Run these on the node of each pod; the source and destination pods may be on different nodes. +1. `cilium_get_endpoints_list` to find the endpoint ID and identity of the pod, then `cilium_get_endpoint_details` with that `endpoint_id`. Check that it is in the `ready` state. +2. `cilium_get_identity_details` with each `identity_id`. Check that the 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` (with `service_id`) for ClusterIP and backend mapping, on the client pod's node. +- `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..a92be9e4 --- /dev/null +++ b/plugin/skills/istio-traffic-debugging/SKILL.md @@ -0,0 +1,29 @@ +--- +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 with `k8s_get_resources`. +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 +Read the response flag from the source pod's `istio-proxy` access log with `k8s_get_pod_logs` (`container: istio-proxy`). + +| 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..b0220199 --- /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` for the crashing container, and with `previous: true` for the run that crashed | +| `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..3424e4f5 --- /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` to find the manifest of each image or workload, then `kubescape_list_vulnerabilities` with its `manifest_name`. The tools do not filter by severity; report 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..6614c641 --- /dev/null +++ b/plugin/skills/prometheus-observability/SKILL.md @@ -0,0 +1,18 @@ +--- +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). + +## 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"