Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 10 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```
Expand Down Expand Up @@ -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/<name>/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
Expand Down
2 changes: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 34 additions & 14 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
ENTRYPOINT ["/opt/kagent-tools/bin/kagent-tools"]
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
21 changes: 19 additions & 2 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
146 changes: 146 additions & 0 deletions cmd/plugin_bundle_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
}
Loading
Loading