diff --git a/api/go.mod b/api/go.mod index 0afb789ea..d607d1fa2 100644 --- a/api/go.mod +++ b/api/go.mod @@ -2,6 +2,8 @@ module github.com/agntcy/dir/api go 1.25.2 +replace github.com/agntcy/oasf-sdk/pkg => ../../oasf-sdk/pkg + require ( buf.build/gen/go/agntcy/oasf-sdk/protocolbuffers/go v1.36.10-20251029125108-823ea6fabc82.1 buf.build/gen/go/agntcy/oasf/protocolbuffers/go v1.36.10-20251022143645-07a420b66e81.1 diff --git a/cli/Dockerfile b/cli/Dockerfile index c3f612d9e..c5dc8a7c7 100644 --- a/cli/Dockerfile +++ b/cli/Dockerfile @@ -15,7 +15,7 @@ RUN --mount=type=cache,id=${TARGETPLATFORM}-apt,target=/var/cache/apt,sharing=lo gcc \ libc6-dev -WORKDIR /build/cli +WORKDIR /build/dir/cli RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ diff --git a/cli/go.mod b/cli/go.mod index 617ad5318..4b908d040 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -9,6 +9,8 @@ replace ( github.com/agntcy/dir/importer => ../importer github.com/agntcy/dir/mcp => ../mcp github.com/agntcy/dir/utils => ../utils + github.com/agntcy/oasf-sdk/pkg => ../../oasf-sdk/pkg + github.com/kagenti/operator => github.com/kagenti/kagenti-operator/kagenti-operator v0.0.0-20251209235923-207524f24e65 ) require ( @@ -180,6 +182,7 @@ require ( github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/kagenti/operator v0.0.0-00010101000000-000000000000 // indirect github.com/klauspost/compress v1.18.1 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/letsencrypt/boulder v0.0.0-20240726163629-a21c417bc04e // indirect @@ -317,6 +320,7 @@ require ( k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect lukechampine.com/blake3 v1.4.1 // indirect + sigs.k8s.io/controller-runtime v0.20.0 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect diff --git a/cli/go.sum b/cli/go.sum index 81e42a865..32b8b01a5 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -694,8 +694,6 @@ github.com/ThalesIgnite/crypto11 v1.2.5 h1:1IiIIEqYmBvUYFeMnHqRft4bwf/O36jryEUpY github.com/ThalesIgnite/crypto11 v1.2.5/go.mod h1:ILDKtnCKiQ7zRoNxcp36Y1ZR8LBPmR2E23+wTQe/MlE= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= -github.com/agntcy/oasf-sdk/pkg v0.0.14 h1:DNKQNf4R4SMDbnaawoSl6FVOBvkSy4O9MyqKd7iHE8I= -github.com/agntcy/oasf-sdk/pkg v0.0.14/go.mod h1:FvcEB49gsvK+JO5i6l/pt5QgTK0LZeR7KYKsdcI6ZIM= github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= @@ -1316,6 +1314,8 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/kagenti/kagenti-operator/kagenti-operator v0.0.0-20251209235923-207524f24e65 h1:vCTulD30lqp5IpZ3Nh6+zQ0I9XBIoDoN4cqZ5mREZl4= +github.com/kagenti/kagenti-operator/kagenti-operator v0.0.0-20251209235923-207524f24e65/go.mod h1:ONO2PmpfRSPDfYAkgIKlABLlBFh9kpQNJg+Es+krw4s= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -2550,6 +2550,8 @@ rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8 rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +sigs.k8s.io/controller-runtime v0.20.0 h1:jjkMo29xEXH+02Md9qaVXfEIaMESSpy3TBWPrsfQkQs= +sigs.k8s.io/controller-runtime v0.20.0/go.mod h1:BrP3w158MwvB3ZbNpaAcIKkHQ7YGpYnzpoSTZ8E14WU= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= diff --git a/client/go.mod b/client/go.mod index f431532c6..c2d1a161e 100644 --- a/client/go.mod +++ b/client/go.mod @@ -5,6 +5,7 @@ go 1.25.2 replace ( github.com/agntcy/dir/api => ../api github.com/agntcy/dir/utils => ../utils + github.com/agntcy/oasf-sdk/pkg => ../../oasf-sdk/pkg ) require ( diff --git a/docker-bake.hcl b/docker-bake.hcl index 9e7822799..75e469d81 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -46,8 +46,8 @@ target "docker-metadata-action" { target "dir-apiserver" { - context = "." - dockerfile = "./server/Dockerfile" + context = ".." + dockerfile = "./dir/server/Dockerfile" target = "production" inherits = [ "_common", @@ -57,8 +57,8 @@ target "dir-apiserver" { } target "dir-apiserver-coverage" { - context = "." - dockerfile = "./server/Dockerfile" + context = ".." + dockerfile = "./dir/server/Dockerfile" target = "coverage" inherits = [ "_common", @@ -68,8 +68,8 @@ target "dir-apiserver-coverage" { } target "dir-ctl" { - context = "." - dockerfile = "./cli/Dockerfile" + context = ".." + dockerfile = "./dir/cli/Dockerfile" inherits = [ "_common", "docker-metadata-action", @@ -78,8 +78,8 @@ target "dir-ctl" { } target "sdks-test" { - context = "." - dockerfile = "./e2e/sdk/Dockerfile" + context = ".." + dockerfile = "./dir/e2e/sdk/Dockerfile" depends_on = ["dir-ctl"] # Ensures dir-ctl is built first inherits = [ "_common", diff --git a/e2e/go.mod b/e2e/go.mod index 392b8d6bd..a936576f5 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -10,6 +10,7 @@ replace ( github.com/agntcy/dir/importer => ../importer github.com/agntcy/dir/mcp => ../mcp github.com/agntcy/dir/utils => ../utils + github.com/agntcy/oasf-sdk/pkg => ../../oasf-sdk/pkg ) require ( diff --git a/e2e/sdk/Dockerfile b/e2e/sdk/Dockerfile index 17075734c..fbb986794 100644 --- a/e2e/sdk/Dockerfile +++ b/e2e/sdk/Dockerfile @@ -15,7 +15,7 @@ ENV DIRECTORY_CLIENT_AUTH_MODE="x509" ENV DIRECTORY_CLIENT_JWT_AUDIENCE="spiffe://dir.example/spire/server" WORKDIR /tmp/ -COPY ./sdk /tmp/ +COPY ./dir/sdk /tmp/ WORKDIR /tmp/dir-py diff --git a/hub/go.mod b/hub/go.mod index 0783a7469..33f5e5507 100644 --- a/hub/go.mod +++ b/hub/go.mod @@ -6,6 +6,7 @@ replace ( github.com/agntcy/dir/api => ../api github.com/agntcy/dir/cli => ../cli github.com/agntcy/dir/utils => ../utils + github.com/agntcy/oasf-sdk/pkg => ../../oasf-sdk/pkg ) require ( diff --git a/importer/go.mod b/importer/go.mod index ab50c5cbc..2daed7af8 100644 --- a/importer/go.mod +++ b/importer/go.mod @@ -6,6 +6,7 @@ replace ( github.com/agntcy/dir/api => ../api github.com/agntcy/dir/client => ../client github.com/agntcy/dir/utils => ../utils + github.com/agntcy/oasf-sdk/pkg => ../../oasf-sdk/pkg ) require ( diff --git a/kagenti-env/README.md b/kagenti-env/README.md new file mode 100644 index 000000000..d1115a0bc --- /dev/null +++ b/kagenti-env/README.md @@ -0,0 +1,61 @@ +# Kagenti Operator - Task Automation + +Automate installation and management using [Task](https://taskfile.dev/). + +## Prerequisites + +```bash +# macOS +brew install go-task kind kubectl helm +``` + +## Install + +```bash +# Create kind cluster + install everything (cert-manager, tekton, operator) +task install:all + +# Or install on existing cluster +task install CLUSTER_NAME=my-cluster + +# Install a specific chart version +task install CLUSTER_NAME=my-cluster CHART_VERSION=0.2.0-alpha.18 +``` + +## Delete + +```bash +# Uninstall operator only +task uninstall CLUSTER_NAME=my-cluster + +# Uninstall everything (operator + tekton + cert-manager) +task uninstall:all CLUSTER_NAME=my-cluster + +# Delete the entire kind cluster +task kind:delete CLUSTER_NAME=my-cluster +``` + +## Useful Commands + +```bash +# Show all available tasks +task --list + +# Check status +task status CLUSTER_NAME=my-cluster + +# View operator logs +task logs CLUSTER_NAME=my-cluster + +# Install from local chart (development) +task install:operator:local CLUSTER_NAME=my-cluster +``` + +## Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `CLUSTER_NAME` | `kagenti` | Kind cluster name | +| `NAMESPACE` | `kagenti-system` | Operator namespace | +| `CHART_VERSION` | `0.2.0-alpha.18` | Helm chart version | +| `CHART_REPO` | `oci://ghcr.io/kagenti/kagenti-operator` | OCI Helm chart repository | diff --git a/kagenti-env/Taskfile.yaml b/kagenti-env/Taskfile.yaml new file mode 100644 index 000000000..c4ea9319f --- /dev/null +++ b/kagenti-env/Taskfile.yaml @@ -0,0 +1,229 @@ +# https://taskfile.dev +version: '3' + +vars: + CLUSTER_NAME: '{{.CLUSTER_NAME | default "kagenti"}}' + NAMESPACE: '{{.NAMESPACE | default "kagenti-system"}}' + HELM_RELEASE: '{{.HELM_RELEASE | default "kagenti-operator"}}' + CHART_VERSION: '{{.CHART_VERSION | default "0.2.0-alpha.18"}}' + CHART_REPO: '{{.CHART_REPO | default "oci://ghcr.io/kagenti/kagenti-operator"}}' + CERT_MANAGER_VERSION: '{{.CERT_MANAGER_VERSION | default "v1.14.0"}}' + TEKTON_VERSION: '{{.TEKTON_VERSION | default "latest"}}' + +tasks: + default: + desc: Show available tasks + cmds: + - task --list + + # ============================================================================ + # Full Installation + # ============================================================================ + + install: + desc: Install the complete kagenti-operator system (cert-manager + tekton + operator) + cmds: + - task: install:deps + - task: install:operator + + install:all: + desc: Create kind cluster and install everything + cmds: + - task: kind:create + - task: install + + # ============================================================================ + # Dependencies + # ============================================================================ + + install:deps: + desc: Install all dependencies (cert-manager + tekton) + cmds: + - task: install:cert-manager + - task: install:tekton + + install:cert-manager: + desc: Install cert-manager + cmds: + - echo "Installing cert-manager {{.CERT_MANAGER_VERSION}}..." + - kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/{{.CERT_MANAGER_VERSION}}/cert-manager.yaml --context kind-{{.CLUSTER_NAME}} + - task: wait:cert-manager + + wait:cert-manager: + desc: Wait for cert-manager to be ready + cmds: + - echo "Waiting for cert-manager webhook to be ready..." + - kubectl wait --for=condition=Available deployment/cert-manager -n cert-manager --timeout=120s --context kind-{{.CLUSTER_NAME}} + - kubectl wait --for=condition=Available deployment/cert-manager-cainjector -n cert-manager --timeout=120s --context kind-{{.CLUSTER_NAME}} + - kubectl wait --for=condition=Available deployment/cert-manager-webhook -n cert-manager --timeout=120s --context kind-{{.CLUSTER_NAME}} + - echo "cert-manager is ready!" + + install:tekton: + desc: Install Tekton Pipelines + cmds: + - echo "Installing Tekton Pipelines..." + - kubectl apply -f https://storage.googleapis.com/tekton-releases/pipeline/{{.TEKTON_VERSION}}/release.yaml --context kind-{{.CLUSTER_NAME}} + - task: wait:tekton + + wait:tekton: + desc: Wait for Tekton to be ready + cmds: + - echo "Waiting for Tekton to be ready..." + - kubectl wait --for=condition=Available deployment/tekton-pipelines-controller -n tekton-pipelines --timeout=120s --context kind-{{.CLUSTER_NAME}} + - kubectl wait --for=condition=Available deployment/tekton-pipelines-webhook -n tekton-pipelines --timeout=120s --context kind-{{.CLUSTER_NAME}} + - echo "Tekton is ready!" + + # ============================================================================ + # Operator Installation + # ============================================================================ + + install:operator: + desc: Install kagenti-operator via Helm (OCI chart from ghcr.io) + cmds: + - echo "Installing kagenti-operator {{.CHART_VERSION}}..." + - | + kubectl create namespace {{.NAMESPACE}} --context kind-{{.CLUSTER_NAME}} --dry-run=client -o yaml | \ + kubectl apply --context kind-{{.CLUSTER_NAME}} -f - && \ + kubectl label namespace {{.NAMESPACE}} --context kind-{{.CLUSTER_NAME}} \ + app.kubernetes.io/managed-by=Helm --overwrite && \ + kubectl annotate namespace {{.NAMESPACE}} --context kind-{{.CLUSTER_NAME}} \ + meta.helm.sh/release-name={{.HELM_RELEASE}} \ + meta.helm.sh/release-namespace={{.NAMESPACE}} --overwrite + - | + helm upgrade --install {{.HELM_RELEASE}} {{.CHART_REPO}}/kagenti-operator-chart \ + --version {{.CHART_VERSION}} \ + --kube-context kind-{{.CLUSTER_NAME}} \ + --namespace {{.NAMESPACE}} \ + --wait + - echo "kagenti-operator installed!" + - task: status + + install:operator:local: + desc: Install kagenti-operator from local chart (for development) + cmds: + - echo "Installing kagenti-operator from local chart..." + - | + kubectl create namespace {{.NAMESPACE}} --context kind-{{.CLUSTER_NAME}} --dry-run=client -o yaml | \ + kubectl apply --context kind-{{.CLUSTER_NAME}} -f - && \ + kubectl label namespace {{.NAMESPACE}} --context kind-{{.CLUSTER_NAME}} \ + app.kubernetes.io/managed-by=Helm --overwrite && \ + kubectl annotate namespace {{.NAMESPACE}} --context kind-{{.CLUSTER_NAME}} \ + meta.helm.sh/release-name={{.HELM_RELEASE}} \ + meta.helm.sh/release-namespace={{.NAMESPACE}} --overwrite + - | + helm upgrade --install {{.HELM_RELEASE}} ./charts/kagenti-operator \ + --kube-context kind-{{.CLUSTER_NAME}} \ + --namespace {{.NAMESPACE}} \ + --wait + - echo "kagenti-operator installed!" + - task: status + + # ============================================================================ + # Kind Cluster Management + # ============================================================================ + + kind:create: + desc: Create a kind cluster + cmds: + - echo "Creating kind cluster '{{.CLUSTER_NAME}}'..." + - kind create cluster --name {{.CLUSTER_NAME}} --wait 60s + - echo "Kind cluster '{{.CLUSTER_NAME}}' created!" + status: + - kind get clusters | grep -q "^{{.CLUSTER_NAME}}$" + + kind:delete: + desc: Delete the kind cluster + cmds: + - echo "Deleting kind cluster '{{.CLUSTER_NAME}}'..." + - kind delete cluster --name {{.CLUSTER_NAME}} + + kind:load-image: + desc: Load a local Docker image into kind (use IMAGE=name:tag) + cmds: + - kind load docker-image {{.IMAGE}} --name {{.CLUSTER_NAME}} + requires: + vars: [IMAGE] + + # ============================================================================ + # Uninstall + # ============================================================================ + + uninstall: + desc: Uninstall kagenti-operator + cmds: + - echo "Uninstalling kagenti-operator..." + - helm uninstall {{.HELM_RELEASE}} --kube-context kind-{{.CLUSTER_NAME}} --namespace {{.NAMESPACE}} || true + - kubectl delete namespace {{.NAMESPACE}} --context kind-{{.CLUSTER_NAME}} || true + + uninstall:all: + desc: Uninstall everything (operator + tekton + cert-manager) + cmds: + - task: uninstall + - echo "Uninstalling Tekton..." + - kubectl delete -f https://storage.googleapis.com/tekton-releases/pipeline/{{.TEKTON_VERSION}}/release.yaml --context kind-{{.CLUSTER_NAME}} || true + - echo "Uninstalling cert-manager..." + - kubectl delete -f https://github.com/cert-manager/cert-manager/releases/download/{{.CERT_MANAGER_VERSION}}/cert-manager.yaml --context kind-{{.CLUSTER_NAME}} || true + + # ============================================================================ + # Status & Debugging + # ============================================================================ + + status: + desc: Show status of all kagenti-operator components + cmds: + - echo "=== Kagenti Operator Status ===" + - kubectl get pods -n {{.NAMESPACE}} --context kind-{{.CLUSTER_NAME}} + - echo "" + - echo "=== CRDs ===" + - kubectl get crds --context kind-{{.CLUSTER_NAME}} | grep -E "kagenti|tekton" || true + - echo "" + - echo "=== Helm Release ===" + - helm list -n {{.NAMESPACE}} --kube-context kind-{{.CLUSTER_NAME}} + + # ============================================================================ + # Development + # ============================================================================ + + kagenti:weather-stack:deploy: + aliases: [deploy] + desc: Deploy weather stack + cmds: + - kubectl apply -f weather_agent.yaml + + kagenti:weather-stack:delete: + aliases: [delete] + desc: Delete weather stack + cmds: + - kubectl delete -f weather_agent.yaml + + kagenti:weather-stack:port-forward: + aliases: [port-forward] + desc: Port forward weather service + cmds: + - kubectl port-forward services/weather-stack 8000 + + kagenti:weather-stack:call: + aliases: [call] + desc: Call weather stack with curl + vars: + MESSAGE: '{{ .MESSAGE | default "What is the weather in Budapest?" }}' + cmd: | + curl -X POST http://localhost:8000 \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": "1", + "method": "message/send", + "params": { + "message": { + "role": "user", + "messageId": "msg-123456", + "parts": [ + { + "kind": "text", + "text": "{{ .MESSAGE }}" + } + ] + } + } + }' diff --git a/kagenti-env/weather_agent.yaml b/kagenti-env/weather_agent.yaml new file mode 100644 index 000000000..a0ae6f9af --- /dev/null +++ b/kagenti-env/weather_agent.yaml @@ -0,0 +1,40 @@ +apiVersion: agent.kagenti.dev/v1alpha1 +kind: Agent +metadata: + name: weather-stack + namespace: default +spec: + description: "Weather agent with tool sidecar" + replicas: 1 + imageSource: + image: "ghcr.io/arpad-csepi/kagenti/weather-service:v0.0.5" + servicePorts: + - name: agent + port: 8000 + protocol: TCP + - name: tool + port: 8001 + protocol: TCP + podTemplateSpec: + spec: + containers: + - name: agent + ports: + - name: http + containerPort: 8000 + protocol: TCP + volumeMounts: + - name: tmp + mountPath: /tmp + - name: tool + image: ghcr.io/arpad-csepi/kagenti/weather-tool:v0.0.5 + ports: + - name: http + containerPort: 8001 + protocol: TCP + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} diff --git a/mcp/go.mod b/mcp/go.mod index cb36e62dd..e0973830e 100644 --- a/mcp/go.mod +++ b/mcp/go.mod @@ -6,6 +6,8 @@ replace ( github.com/agntcy/dir/api => ../api github.com/agntcy/dir/client => ../client github.com/agntcy/dir/utils => ../utils + github.com/agntcy/oasf-sdk/pkg => ../../oasf-sdk/pkg + github.com/kagenti/operator => github.com/kagenti/kagenti-operator/kagenti-operator v0.0.0-20251209235923-207524f24e65 ) require ( @@ -15,6 +17,8 @@ require ( github.com/modelcontextprotocol/go-sdk v0.8.0 github.com/stretchr/testify v1.11.1 google.golang.org/protobuf v1.36.10 + k8s.io/apimachinery v0.33.2 + k8s.io/client-go v0.33.2 ) require ( @@ -108,6 +112,7 @@ require ( github.com/ipfs/go-cid v0.5.0 // indirect github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/kagenti/operator v0.0.0-00010101000000-000000000000 // indirect github.com/klauspost/compress v1.18.1 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/letsencrypt/boulder v0.0.0-20240726163629-a21c417bc04e // indirect @@ -204,12 +209,11 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.33.2 // indirect - k8s.io/apimachinery v0.33.2 // indirect - k8s.io/client-go v0.33.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect lukechampine.com/blake3 v1.4.0 // indirect + sigs.k8s.io/controller-runtime v0.20.0 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect diff --git a/mcp/go.sum b/mcp/go.sum index 423a9e2bc..11702a945 100644 --- a/mcp/go.sum +++ b/mcp/go.sum @@ -685,8 +685,6 @@ github.com/ThalesIgnite/crypto11 v1.2.5 h1:1IiIIEqYmBvUYFeMnHqRft4bwf/O36jryEUpY github.com/ThalesIgnite/crypto11 v1.2.5/go.mod h1:ILDKtnCKiQ7zRoNxcp36Y1ZR8LBPmR2E23+wTQe/MlE= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= -github.com/agntcy/oasf-sdk/pkg v0.0.14 h1:DNKQNf4R4SMDbnaawoSl6FVOBvkSy4O9MyqKd7iHE8I= -github.com/agntcy/oasf-sdk/pkg v0.0.14/go.mod h1:FvcEB49gsvK+JO5i6l/pt5QgTK0LZeR7KYKsdcI6ZIM= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= @@ -1187,6 +1185,8 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/kagenti/kagenti-operator/kagenti-operator v0.0.0-20251209235923-207524f24e65 h1:vCTulD30lqp5IpZ3Nh6+zQ0I9XBIoDoN4cqZ5mREZl4= +github.com/kagenti/kagenti-operator/kagenti-operator v0.0.0-20251209235923-207524f24e65/go.mod h1:ONO2PmpfRSPDfYAkgIKlABLlBFh9kpQNJg+Es+krw4s= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -2273,6 +2273,8 @@ rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8 rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +sigs.k8s.io/controller-runtime v0.20.0 h1:jjkMo29xEXH+02Md9qaVXfEIaMESSpy3TBWPrsfQkQs= +sigs.k8s.io/controller-runtime v0.20.0/go.mod h1:BrP3w158MwvB3ZbNpaAcIKkHQ7YGpYnzpoSTZ8E14WU= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= diff --git a/mcp/prompts/call_agent.go b/mcp/prompts/call_agent.go new file mode 100644 index 000000000..fb6224050 --- /dev/null +++ b/mcp/prompts/call_agent.go @@ -0,0 +1,63 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package prompts + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// CallAgentInput defines the input parameters for the call_agent prompt. +type CallAgentInput struct { + Message string `json:"message" jsonschema:"The question or message to send to the agent (required)"` + Endpoint string `json:"endpoint" jsonschema:"The A2A agent endpoint URL (default: http://localhost:8000)"` +} + +// CallAgent implements the call_agent prompt. +// It sends a message to a deployed A2A agent and returns the response. +func CallAgent(_ context.Context, req *mcp.GetPromptRequest) ( + *mcp.GetPromptResult, + error, +) { + args := req.Params.Arguments + + message := args["message"] + if message == "" { + message = "[User will provide their question]" + } + + endpoint := args["endpoint"] + if endpoint == "" { + endpoint = "http://localhost:8000" + } + + promptText := fmt.Sprintf(strings.TrimSpace(` +Send a message to a deployed A2A agent and return the response. + +Message: "%s" +Endpoint: %s + +Use agntcy_a2a_call with: +- message: "%s" +- endpoint: "%s" + +Return the agent's response to the user. + `), message, endpoint, message, endpoint) + + return &mcp.GetPromptResult{ + Description: "Call a deployed A2A agent with a message", + Messages: []*mcp.PromptMessage{ + { + Role: "user", + Content: &mcp.TextContent{ + Text: promptText, + }, + }, + }, + }, nil +} + diff --git a/mcp/prompts/call_agent_test.go b/mcp/prompts/call_agent_test.go new file mode 100644 index 000000000..c9bbf0413 --- /dev/null +++ b/mcp/prompts/call_agent_test.go @@ -0,0 +1,79 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package prompts + +import ( + "context" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCallAgent(t *testing.T) { + t.Run("should return prompt with message and default endpoint", func(t *testing.T) { + ctx := context.Background() + req := &mcp.GetPromptRequest{ + Params: &mcp.GetPromptParams{ + Arguments: map[string]string{ + "message": "What is the weather in Budapest?", + }, + }, + } + + result, err := CallAgent(ctx, req) + require.NoError(t, err) + assert.NotNil(t, result) + assert.NotEmpty(t, result.Messages) + + textContent, ok := result.Messages[0].Content.(*mcp.TextContent) + require.True(t, ok) + + content := textContent.Text + assert.Contains(t, content, "What is the weather in Budapest?") + assert.Contains(t, content, "http://localhost:8000") + assert.Contains(t, content, "agntcy_a2a_call") + }) + + t.Run("should use custom endpoint when provided", func(t *testing.T) { + ctx := context.Background() + req := &mcp.GetPromptRequest{ + Params: &mcp.GetPromptParams{ + Arguments: map[string]string{ + "message": "Hello", + "endpoint": "http://my-agent:9000", + }, + }, + } + + result, err := CallAgent(ctx, req) + require.NoError(t, err) + + textContent, ok := result.Messages[0].Content.(*mcp.TextContent) + require.True(t, ok) + + content := textContent.Text + assert.Contains(t, content, "http://my-agent:9000") + }) + + t.Run("should handle empty message", func(t *testing.T) { + ctx := context.Background() + req := &mcp.GetPromptRequest{ + Params: &mcp.GetPromptParams{ + Arguments: map[string]string{}, + }, + } + + result, err := CallAgent(ctx, req) + require.NoError(t, err) + + textContent, ok := result.Messages[0].Content.(*mcp.TextContent) + require.True(t, ok) + + content := textContent.Text + assert.Contains(t, content, "[User will provide their question]") + }) +} + diff --git a/mcp/prompts/delete_agent.go b/mcp/prompts/delete_agent.go new file mode 100644 index 000000000..4edbe4b58 --- /dev/null +++ b/mcp/prompts/delete_agent.go @@ -0,0 +1,63 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package prompts + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// DeleteAgentInput defines the input parameters for the delete_agent prompt. +type DeleteAgentInput struct { + AgentName string `json:"agent_name" jsonschema:"Name of the agent to delete (required)"` + Namespace string `json:"namespace" jsonschema:"Kubernetes namespace (default: default)"` +} + +// DeleteAgent implements the delete_agent prompt. +// It deletes a deployed agent from Kubernetes. +func DeleteAgent(_ context.Context, req *mcp.GetPromptRequest) ( + *mcp.GetPromptResult, + error, +) { + args := req.Params.Arguments + + agentName := args["agent_name"] + if agentName == "" { + agentName = "[User will provide agent name]" + } + + namespace := args["namespace"] + if namespace == "" { + namespace = "default" + } + + promptText := fmt.Sprintf(strings.TrimSpace(` +Delete a deployed agent from Kubernetes. + +Agent name: %s +Namespace: %s + +Use agntcy_kagenti_delete with: +- agent_name: "%s" +- namespace: "%s" + +Confirm the deletion to the user. + `), agentName, namespace, agentName, namespace) + + return &mcp.GetPromptResult{ + Description: "Delete a deployed agent from Kubernetes", + Messages: []*mcp.PromptMessage{ + { + Role: "user", + Content: &mcp.TextContent{ + Text: promptText, + }, + }, + }, + }, nil +} + diff --git a/mcp/prompts/delete_agent_test.go b/mcp/prompts/delete_agent_test.go new file mode 100644 index 000000000..9c5fe41df --- /dev/null +++ b/mcp/prompts/delete_agent_test.go @@ -0,0 +1,80 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package prompts + +import ( + "context" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeleteAgent(t *testing.T) { + t.Run("should return prompt with agent name and default namespace", func(t *testing.T) { + ctx := context.Background() + req := &mcp.GetPromptRequest{ + Params: &mcp.GetPromptParams{ + Arguments: map[string]string{ + "agent_name": "weather-service", + }, + }, + } + + result, err := DeleteAgent(ctx, req) + require.NoError(t, err) + assert.NotNil(t, result) + assert.NotEmpty(t, result.Messages) + + textContent, ok := result.Messages[0].Content.(*mcp.TextContent) + require.True(t, ok) + + content := textContent.Text + assert.Contains(t, content, "weather-service") + assert.Contains(t, content, "default") + assert.Contains(t, content, "agntcy_kagenti_delete") + }) + + t.Run("should use custom namespace when provided", func(t *testing.T) { + ctx := context.Background() + req := &mcp.GetPromptRequest{ + Params: &mcp.GetPromptParams{ + Arguments: map[string]string{ + "agent_name": "my-agent", + "namespace": "production", + }, + }, + } + + result, err := DeleteAgent(ctx, req) + require.NoError(t, err) + + textContent, ok := result.Messages[0].Content.(*mcp.TextContent) + require.True(t, ok) + + content := textContent.Text + assert.Contains(t, content, "my-agent") + assert.Contains(t, content, "production") + }) + + t.Run("should handle empty agent_name", func(t *testing.T) { + ctx := context.Background() + req := &mcp.GetPromptRequest{ + Params: &mcp.GetPromptParams{ + Arguments: map[string]string{}, + }, + } + + result, err := DeleteAgent(ctx, req) + require.NoError(t, err) + + textContent, ok := result.Messages[0].Content.(*mcp.TextContent) + require.True(t, ok) + + content := textContent.Text + assert.Contains(t, content, "[User will provide agent name]") + }) +} + diff --git a/mcp/prompts/deploy_agent.go b/mcp/prompts/deploy_agent.go new file mode 100644 index 000000000..32ce8b1f4 --- /dev/null +++ b/mcp/prompts/deploy_agent.go @@ -0,0 +1,82 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package prompts + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// DeployAgentInput defines the input parameters for the deploy_agent prompt. +type DeployAgentInput struct { + Description string `json:"description" jsonschema:"Natural language description of what kind of agent you need (e.g., 'find me a code review assistant') (required)"` + Namespace string `json:"namespace" jsonschema:"Kubernetes namespace to deploy to (default: 'default')"` + Replicas string `json:"replicas" jsonschema:"Number of pod replicas to deploy (default: '1')"` +} + +// DeployAgent implements the deploy_agent prompt. +// It guides users through the complete workflow of searching, pulling, exporting, and deploying an agent. +func DeployAgent(_ context.Context, req *mcp.GetPromptRequest) ( + *mcp.GetPromptResult, + error, +) { + // Parse arguments from the request + args := req.Params.Arguments + + description := args["description"] + if description == "" { + description = "[User will describe what agent they need]" + } + + namespace := args["namespace"] + if namespace == "" { + namespace = "default" + } + + replicas := args["replicas"] + if replicas == "" { + replicas = "1" + } + + promptText := fmt.Sprintf(strings.TrimSpace(` +Find and deploy an agent from the Directory to Kubernetes. + +User request: "%s" +Target namespace: %s +Replicas: %s + +## Workflow + +1. **Search**: Use agntcy_dir_search_local to find agents matching the user's request +2. **Pull**: Use agntcy_dir_pull_record with the CID from search results +3. **Export**: Use agntcy_oasf_export_record with target_format "kagenti" +4. **Deploy**: Use agntcy_kagenti_deploy to namespace "%s" with %s replica(s) + +## After Deployment + +Provide the user with: +- kubectl get agents -n %s +- kubectl port-forward svc/ 8000:8000 -n %s + +Start by searching for agents that match: "%s" + `), description, namespace, replicas, + namespace, replicas, + namespace, namespace, + description) + + return &mcp.GetPromptResult{ + Description: "Deploy an agent from the Directory to a Kubernetes cluster via Kagenti", + Messages: []*mcp.PromptMessage{ + { + Role: "user", + Content: &mcp.TextContent{ + Text: promptText, + }, + }, + }, + }, nil +} diff --git a/mcp/prompts/deploy_agent_test.go b/mcp/prompts/deploy_agent_test.go new file mode 100644 index 000000000..6fb4c8741 --- /dev/null +++ b/mcp/prompts/deploy_agent_test.go @@ -0,0 +1,133 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package prompts + +import ( + "context" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeployAgent(t *testing.T) { + t.Run("should return prompt with all parameters", func(t *testing.T) { + ctx := context.Background() + req := &mcp.GetPromptRequest{ + Params: &mcp.GetPromptParams{ + Arguments: map[string]string{ + "description": "I need an agent that can tell me the weather forecast", + "namespace": "production", + "replicas": "3", + }, + }, + } + + result, err := DeployAgent(ctx, req) + require.NoError(t, err) + assert.NotNil(t, result) + assert.NotEmpty(t, result.Messages) + assert.Len(t, result.Messages, 1) + assert.Equal(t, mcp.Role("user"), result.Messages[0].Role) + + textContent, ok := result.Messages[0].Content.(*mcp.TextContent) + require.True(t, ok, "Content should be TextContent") + + content := textContent.Text + assert.Contains(t, content, "weather forecast") + assert.Contains(t, content, "production") + assert.Contains(t, content, "3") + assert.Contains(t, content, "agntcy_dir_search_local") + assert.Contains(t, content, "agntcy_dir_pull_record") + assert.Contains(t, content, "agntcy_oasf_export_record") + assert.Contains(t, content, "agntcy_kagenti_deploy") + }) + + t.Run("should use default values when parameters missing", func(t *testing.T) { + ctx := context.Background() + req := &mcp.GetPromptRequest{ + Params: &mcp.GetPromptParams{ + Arguments: map[string]string{ + "description": "find me a translation agent", + }, + }, + } + + result, err := DeployAgent(ctx, req) + require.NoError(t, err) + assert.NotNil(t, result) + + textContent, ok := result.Messages[0].Content.(*mcp.TextContent) + require.True(t, ok, "Content should be TextContent") + + content := textContent.Text + assert.Contains(t, content, "default") + assert.Contains(t, content, "translation") + }) + + t.Run("should handle empty description", func(t *testing.T) { + ctx := context.Background() + req := &mcp.GetPromptRequest{ + Params: &mcp.GetPromptParams{ + Arguments: map[string]string{}, + }, + } + + result, err := DeployAgent(ctx, req) + require.NoError(t, err) + assert.NotNil(t, result) + + textContent, ok := result.Messages[0].Content.(*mcp.TextContent) + require.True(t, ok, "Content should be TextContent") + + content := textContent.Text + assert.Contains(t, content, "[User will describe what agent they need]") + }) + + t.Run("should include workflow steps", func(t *testing.T) { + ctx := context.Background() + req := &mcp.GetPromptRequest{ + Params: &mcp.GetPromptParams{ + Arguments: map[string]string{ + "description": "I want an agent for code review", + }, + }, + } + + result, err := DeployAgent(ctx, req) + require.NoError(t, err) + + textContent, ok := result.Messages[0].Content.(*mcp.TextContent) + require.True(t, ok, "Content should be TextContent") + + content := textContent.Text + assert.Contains(t, content, "Search") + assert.Contains(t, content, "Pull") + assert.Contains(t, content, "Export") + assert.Contains(t, content, "Deploy") + }) + + t.Run("should include kubectl commands for verification", func(t *testing.T) { + ctx := context.Background() + req := &mcp.GetPromptRequest{ + Params: &mcp.GetPromptParams{ + Arguments: map[string]string{ + "description": "deploy a personal assistant agent", + "namespace": "agents", + }, + }, + } + + result, err := DeployAgent(ctx, req) + require.NoError(t, err) + + textContent, ok := result.Messages[0].Content.(*mcp.TextContent) + require.True(t, ok, "Content should be TextContent") + + content := textContent.Text + assert.Contains(t, content, "kubectl get agents -n agents") + assert.Contains(t, content, "kubectl port-forward") + }) +} diff --git a/mcp/server/server.go b/mcp/server/server.go index 2a0b9be53..ba5721ca4 100644 --- a/mcp/server/server.go +++ b/mcp/server/server.go @@ -193,6 +193,7 @@ This tool takes an OASF record in JSON format and converts it to the specified t Currently supported target formats: - "a2a": Agent-to-Agent (A2A) format - "ghcopilot": GitHub Copilot MCP configuration format +- "kagenti": Kagenti Agent Spec format **Input Format**: Provide the OASF record as a standard JSON object (no wrapper needed). @@ -201,11 +202,87 @@ Provide the OASF record as a standard JSON object (no wrapper needed). The output structure depends on the target format: - For "a2a": Returns the A2A card directly as a JSON object - For "ghcopilot": Returns the GitHub Copilot MCP configuration as a JSON object +- For "kagenti": Returns the Kagenti Agent Spec as a JSON object Use this tool when you need to convert OASF records to other format specifications. `), }, tools.ExportRecord) + // Add tool for deploying agents to Kubernetes via Kagenti operator + mcp.AddTool(server, &mcp.Tool{ + Name: "agntcy_kagenti_deploy", + Description: strings.TrimSpace(` +Deploys a Kagenti Agent CR to Kubernetes. +This tool takes a pre-built Kagenti Agent Custom Resource as a JSON string +and applies it to the Kubernetes cluster. + +**Prerequisites**: +- Kagenti operator must be installed in the cluster +- Valid kubeconfig or in-cluster configuration + +**Input**: +- agent_json: Marshalled Kagenti Agent CR as JSON string (required) +- namespace: Kubernetes namespace to deploy to (default: "default") +- replicas: Number of pod replicas (default: 1) + +**Output**: +- agent_name: Name of the created/updated Agent CR +- namespace: Namespace where deployed +- created: True if created, false if updated + +The Agent CR JSON must have apiVersion "agent.kagenti.dev/v1alpha1" and kind "Agent". +Use other tools to pull OASF records and transform them into Agent CRs before deploying. + `), + }, tools.DeployKagenti) + + // Add tool for deleting agents deployed via Kagenti operator + mcp.AddTool(server, &mcp.Tool{ + Name: "agntcy_kagenti_delete", + Description: strings.TrimSpace(` +Deletes a Kagenti Agent CR from Kubernetes. +This tool removes a deployed agent by its name. + +**Prerequisites**: +- Kagenti operator must be installed in the cluster +- Valid kubeconfig or in-cluster configuration +- The Agent CR must exist + +**Input**: +- agent_name: Name of the Agent CR to delete (required) +- namespace: Kubernetes namespace (default: "default") + +**Output**: +- agent_name: Name of the deleted Agent CR +- namespace: Namespace where the agent was deployed +- deleted: True if successfully deleted + +Use this tool to remove agents that are no longer needed. + `), + }, tools.DeleteKagenti) + + // Add tool for calling A2A agents + mcp.AddTool(server, &mcp.Tool{ + Name: "agntcy_a2a_call", + Description: strings.TrimSpace(` +Sends a message to an A2A (Agent-to-Agent) agent and returns the response. +This tool allows you to interact with deployed A2A agents directly. + +**Prerequisites**: +- The A2A agent must be running and accessible +- Port-forward must be active if the agent is in Kubernetes + +**Input**: +- message: The text message/question to send to the agent (required) +- endpoint: The A2A agent endpoint URL (default: "http://localhost:8000") + +**Output**: +- response: The agent's response text +- raw_response: The full JSON-RPC response + +Use this tool to test deployed agents or interact with them for demos. + `), + }, tools.CallA2AAgent) + // Add tool for importing records from other formats to OASF mcp.AddTool(server, &mcp.Tool{ Name: "agntcy_oasf_import_record", @@ -411,6 +488,77 @@ This guided workflow includes: }, }, prompts.ExportRecord) + // Add prompt for deploying agents from Directory to Kubernetes + server.AddPrompt(&mcp.Prompt{ + Name: "deploy_agent", + Description: strings.TrimSpace(` +Complete workflow for finding and deploying an agent from the Directory to Kubernetes. +Describe what kind of agent you need in natural language, and this workflow will: +- Analyze your request and search for matching agents +- Pull and inspect the OASF record +- Convert to Kagenti Agent spec +- Deploy to a Kubernetes cluster with Kagenti operator + `), + Arguments: []*mcp.PromptArgument{ + { + Name: "description", + Description: "Natural language description of what agent you need (e.g., 'find me a code review assistant')", + Required: true, + }, + { + Name: "namespace", + Description: "Kubernetes namespace to deploy to (default: 'default')", + Required: false, + }, + { + Name: "replicas", + Description: "Number of pod replicas to deploy (default: '1')", + Required: false, + }, + }, + }, prompts.DeployAgent) + + // Add prompt for calling A2A agents + server.AddPrompt(&mcp.Prompt{ + Name: "call_agent", + Description: strings.TrimSpace(` +Send a message to a deployed A2A agent and get the response. +Use this to interact with agents that have been deployed to Kubernetes. + `), + Arguments: []*mcp.PromptArgument{ + { + Name: "message", + Description: "The question or message to send to the agent", + Required: true, + }, + { + Name: "endpoint", + Description: "The A2A agent endpoint URL (default: http://localhost:8000)", + Required: false, + }, + }, + }, prompts.CallAgent) + + // Add prompt for deleting agents from Kubernetes + server.AddPrompt(&mcp.Prompt{ + Name: "delete_agent", + Description: strings.TrimSpace(` +Delete a deployed agent from Kubernetes. + `), + Arguments: []*mcp.PromptArgument{ + { + Name: "agent_name", + Description: "Name of the agent to delete", + Required: true, + }, + { + Name: "namespace", + Description: "Kubernetes namespace (default: default)", + Required: false, + }, + }, + }, prompts.DeleteAgent) + // Run the server over stdin/stdout if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil { return fmt.Errorf("failed to run MCP server: %w", err) diff --git a/mcp/tools/call_a2a_agent.go b/mcp/tools/call_a2a_agent.go new file mode 100644 index 000000000..7007c3988 --- /dev/null +++ b/mcp/tools/call_a2a_agent.go @@ -0,0 +1,198 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package tools + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// CallA2AAgentInput defines the input parameters for calling an A2A agent. +type CallA2AAgentInput struct { + // Message is the text message to send to the agent + Message string `json:"message" jsonschema:"The message/question to send to the agent (required)"` + // Endpoint is the URL of the A2A agent (default: http://localhost:8000) + Endpoint string `json:"endpoint,omitempty" jsonschema:"The A2A agent endpoint URL (default: http://localhost:8000)"` +} + +// CallA2AAgentOutput defines the output of calling an A2A agent. +type CallA2AAgentOutput struct { + Response string `json:"response,omitempty" jsonschema:"The agent's response text"` + RawResponse string `json:"raw_response,omitempty" jsonschema:"The full JSON-RPC response"` + ErrorMessage string `json:"error_message,omitempty" jsonschema:"Error message if the call failed"` +} + +// a2aRequest represents the JSON-RPC request structure for A2A protocol. +type a2aRequest struct { + JSONRPC string `json:"jsonrpc"` + ID string `json:"id"` + Method string `json:"method"` + Params a2aParams `json:"params"` +} + +type a2aParams struct { + Message a2aMessage `json:"message"` +} + +type a2aMessage struct { + Role string `json:"role"` + MessageID string `json:"messageId"` + Parts []a2aPart `json:"parts"` +} + +type a2aPart struct { + Kind string `json:"kind"` + Text string `json:"text"` +} + +// a2aResponse represents the JSON-RPC response structure. +type a2aResponse struct { + JSONRPC string `json:"jsonrpc"` + ID string `json:"id"` + Result *a2aResult `json:"result,omitempty"` + Error *a2aError `json:"error,omitempty"` +} + +type a2aResult struct { + Artifacts []a2aArtifact `json:"artifacts,omitempty"` +} + +type a2aArtifact struct { + ArtifactID string `json:"artifactId"` + Parts []a2aPart `json:"parts,omitempty"` +} + +type a2aError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// CallA2AAgent sends a message to an A2A agent and returns the response. +func CallA2AAgent(ctx context.Context, _ *mcp.CallToolRequest, input CallA2AAgentInput) ( + *mcp.CallToolResult, + CallA2AAgentOutput, + error, +) { + // Validate input + if input.Message == "" { + return nil, CallA2AAgentOutput{ + ErrorMessage: "message is required", + }, nil + } + + // Set default endpoint + endpoint := input.Endpoint + if endpoint == "" { + endpoint = "http://localhost:8000" + } + + // Build the A2A request + req := a2aRequest{ + JSONRPC: "2.0", + ID: uuid.New().String(), + Method: "message/send", + Params: a2aParams{ + Message: a2aMessage{ + Role: "user", + MessageID: uuid.New().String(), + Parts: []a2aPart{ + { + Kind: "text", + Text: input.Message, + }, + }, + }, + }, + } + + // Marshal request to JSON + reqBody, err := json.Marshal(req) + if err != nil { + return nil, CallA2AAgentOutput{ + ErrorMessage: fmt.Sprintf("Failed to marshal request: %v", err), + }, nil + } + + // Create HTTP request + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(reqBody)) + if err != nil { + return nil, CallA2AAgentOutput{ + ErrorMessage: fmt.Sprintf("Failed to create HTTP request: %v", err), + }, nil + } + httpReq.Header.Set("Content-Type", "application/json") + + // Send request with timeout + client := &http.Client{ + Timeout: 60 * time.Second, + } + resp, err := client.Do(httpReq) + if err != nil { + return nil, CallA2AAgentOutput{ + ErrorMessage: fmt.Sprintf("Failed to send request to agent: %v", err), + }, nil + } + defer resp.Body.Close() + + // Read response body + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, CallA2AAgentOutput{ + ErrorMessage: fmt.Sprintf("Failed to read response: %v", err), + }, nil + } + + // Check HTTP status + if resp.StatusCode != http.StatusOK { + return nil, CallA2AAgentOutput{ + ErrorMessage: fmt.Sprintf("Agent returned HTTP %d: %s", resp.StatusCode, string(respBody)), + }, nil + } + + // Parse JSON-RPC response + var a2aResp a2aResponse + if err := json.Unmarshal(respBody, &a2aResp); err != nil { + return nil, CallA2AAgentOutput{ + ErrorMessage: fmt.Sprintf("Failed to parse response: %v", err), + RawResponse: string(respBody), + }, nil + } + + // Check for JSON-RPC error + if a2aResp.Error != nil { + return nil, CallA2AAgentOutput{ + ErrorMessage: fmt.Sprintf("Agent error (%d): %s", a2aResp.Error.Code, a2aResp.Error.Message), + RawResponse: string(respBody), + }, nil + } + + // Extract response text from artifacts + var responseText string + if a2aResp.Result != nil && len(a2aResp.Result.Artifacts) > 0 { + for _, artifact := range a2aResp.Result.Artifacts { + for _, part := range artifact.Parts { + if part.Kind == "text" { + if responseText != "" { + responseText += "\n" + } + responseText += part.Text + } + } + } + } + + return nil, CallA2AAgentOutput{ + Response: responseText, + RawResponse: string(respBody), + }, nil +} + diff --git a/mcp/tools/call_a2a_agent_test.go b/mcp/tools/call_a2a_agent_test.go new file mode 100644 index 000000000..7a8b0dd96 --- /dev/null +++ b/mcp/tools/call_a2a_agent_test.go @@ -0,0 +1,148 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package tools + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCallA2AAgent(t *testing.T) { + t.Run("should return error when message is empty", func(t *testing.T) { + ctx := context.Background() + input := CallA2AAgentInput{ + Message: "", + } + + _, output, err := CallA2AAgent(ctx, nil, input) + require.NoError(t, err) + assert.Contains(t, output.ErrorMessage, "message is required") + }) + + t.Run("should use default endpoint when not provided", func(t *testing.T) { + // This test just verifies the input parsing, not the actual HTTP call + input := CallA2AAgentInput{ + Message: "What is the weather?", + } + assert.Equal(t, "", input.Endpoint) // Will default to localhost:8000 in the function + }) + + t.Run("should successfully call agent and parse response", func(t *testing.T) { + // Create a mock A2A server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + // Parse request body + var req a2aRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + assert.Equal(t, "2.0", req.JSONRPC) + assert.Equal(t, "message/send", req.Method) + assert.Equal(t, "user", req.Params.Message.Role) + assert.Len(t, req.Params.Message.Parts, 1) + assert.Equal(t, "text", req.Params.Message.Parts[0].Kind) + assert.Equal(t, "What is the weather in Budapest?", req.Params.Message.Parts[0].Text) + + // Send mock response + resp := map[string]interface{}{ + "jsonrpc": "2.0", + "id": req.ID, + "result": map[string]interface{}{ + "artifacts": []map[string]interface{}{ + { + "artifactId": "test-artifact", + "parts": []map[string]interface{}{ + { + "kind": "text", + "text": "The weather in Budapest is sunny, 25°C.", + }, + }, + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + ctx := context.Background() + input := CallA2AAgentInput{ + Message: "What is the weather in Budapest?", + Endpoint: server.URL, + } + + _, output, err := CallA2AAgent(ctx, nil, input) + require.NoError(t, err) + assert.Empty(t, output.ErrorMessage) + assert.Equal(t, "The weather in Budapest is sunny, 25°C.", output.Response) + assert.NotEmpty(t, output.RawResponse) + }) + + t.Run("should handle agent error response", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "jsonrpc": "2.0", + "id": "1", + "error": map[string]interface{}{ + "code": -32600, + "message": "Invalid request", + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + ctx := context.Background() + input := CallA2AAgentInput{ + Message: "test", + Endpoint: server.URL, + } + + _, output, err := CallA2AAgent(ctx, nil, input) + require.NoError(t, err) + assert.Contains(t, output.ErrorMessage, "Agent error") + assert.Contains(t, output.ErrorMessage, "Invalid request") + }) + + t.Run("should handle HTTP error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal Server Error")) + })) + defer server.Close() + + ctx := context.Background() + input := CallA2AAgentInput{ + Message: "test", + Endpoint: server.URL, + } + + _, output, err := CallA2AAgent(ctx, nil, input) + require.NoError(t, err) + assert.Contains(t, output.ErrorMessage, "HTTP 500") + }) + + t.Run("should handle connection error", func(t *testing.T) { + ctx := context.Background() + input := CallA2AAgentInput{ + Message: "test", + Endpoint: "http://localhost:99999", // Invalid port + } + + _, output, err := CallA2AAgent(ctx, nil, input) + require.NoError(t, err) + assert.Contains(t, output.ErrorMessage, "Failed to send request") + }) +} + diff --git a/mcp/tools/delete_kagenti.go b/mcp/tools/delete_kagenti.go new file mode 100644 index 000000000..742fc3c05 --- /dev/null +++ b/mcp/tools/delete_kagenti.go @@ -0,0 +1,79 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package tools + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// DeleteKagentiInput defines the input parameters for deleting a Kagenti agent. +type DeleteKagentiInput struct { + // AgentName is the name of the Agent CR to delete + AgentName string `json:"agent_name" jsonschema:"Name of the Agent CR to delete (required)"` + // Namespace is the Kubernetes namespace where the agent is deployed + Namespace string `json:"namespace,omitempty" jsonschema:"Kubernetes namespace (default: default)"` +} + +// DeleteKagentiOutput defines the output of deleting an agent. +type DeleteKagentiOutput struct { + AgentName string `json:"agent_name,omitempty" jsonschema:"Name of the deleted Agent CR"` + Namespace string `json:"namespace,omitempty" jsonschema:"Namespace where the agent was deployed"` + Deleted bool `json:"deleted" jsonschema:"True if the agent was successfully deleted"` + ErrorMessage string `json:"error_message,omitempty" jsonschema:"Error message if deletion failed"` +} + +// DeleteKagenti deletes a Kagenti Agent CR from Kubernetes. +func DeleteKagenti(ctx context.Context, _ *mcp.CallToolRequest, input DeleteKagentiInput) ( + *mcp.CallToolResult, + DeleteKagentiOutput, + error, +) { + // Validate input + if input.AgentName == "" { + return nil, DeleteKagentiOutput{ + ErrorMessage: "agent_name is required", + }, nil + } + + // Determine namespace (default to "default") + namespace := input.Namespace + if namespace == "" { + namespace = "default" + } + + // Get Kubernetes client + k8sClient, err := getK8sClient() + if err != nil { + return nil, DeleteKagentiOutput{ + ErrorMessage: fmt.Sprintf("Failed to create Kubernetes client: %v", err), + }, nil + } + + // Define the GVR for Agent resources + gvr := schema.GroupVersionResource{ + Group: KagentiAPIGroup, + Version: KagentiAPIVersion, + Resource: KagentiAgentResource, + } + + // Delete the resource + err = k8sClient.Resource(gvr).Namespace(namespace).Delete(ctx, input.AgentName, metav1.DeleteOptions{}) + if err != nil { + return nil, DeleteKagentiOutput{ + ErrorMessage: fmt.Sprintf("Failed to delete Agent CR '%s' in namespace '%s': %v", input.AgentName, namespace, err), + }, nil + } + + return nil, DeleteKagentiOutput{ + AgentName: input.AgentName, + Namespace: namespace, + Deleted: true, + }, nil +} + diff --git a/mcp/tools/delete_kagenti_test.go b/mcp/tools/delete_kagenti_test.go new file mode 100644 index 000000000..f5d9ad479 --- /dev/null +++ b/mcp/tools/delete_kagenti_test.go @@ -0,0 +1,71 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package tools + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeleteKagenti(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("fails when agent_name is empty", func(t *testing.T) { + t.Parallel() + + input := DeleteKagentiInput{ + AgentName: "", + Namespace: "default", + } + + _, output, err := DeleteKagenti(ctx, nil, input) + + require.NoError(t, err) + assert.Contains(t, output.ErrorMessage, "agent_name is required") + assert.Empty(t, output.AgentName) + assert.False(t, output.Deleted) + }) + + t.Run("defaults namespace to 'default' when not provided", func(t *testing.T) { + t.Parallel() + + input := DeleteKagentiInput{ + AgentName: "test-agent", + Namespace: "", // Should default to "default" + } + + _, output, err := DeleteKagenti(ctx, nil, input) + + require.NoError(t, err) + // Will fail on K8s (either connection or not found), but passed validation + // The important thing is it attempted the delete with correct namespace + if output.ErrorMessage != "" { + assert.Contains(t, output.ErrorMessage, "test-agent") + } + }) + + t.Run("uses provided namespace", func(t *testing.T) { + t.Parallel() + + input := DeleteKagentiInput{ + AgentName: "test-agent", + Namespace: "custom-namespace", + } + + _, output, err := DeleteKagenti(ctx, nil, input) + + require.NoError(t, err) + // Will fail on K8s (either connection or not found), but passed validation + // The important thing is it attempted the delete with correct namespace + if output.ErrorMessage != "" { + assert.Contains(t, output.ErrorMessage, "custom-namespace") + } + }) +} + diff --git a/mcp/tools/deploy_kagenti.go b/mcp/tools/deploy_kagenti.go new file mode 100644 index 000000000..b9a205206 --- /dev/null +++ b/mcp/tools/deploy_kagenti.go @@ -0,0 +1,181 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +const ( + // KagentiAPIGroup is the API group for Kagenti resources. + KagentiAPIGroup = "agent.kagenti.dev" + // KagentiAPIVersion is the API version for Kagenti resources. + KagentiAPIVersion = "v1alpha1" + // KagentiAgentResource is the resource name for Agent resources. + KagentiAgentResource = "agents" +) + +// DeployKagentiInput defines the input parameters for deploying an agent via Kagenti. +type DeployKagentiInput struct { + // AgentJSON is the marshalled Kagenti Agent CR JSON string + AgentJSON string `json:"agent_json" jsonschema:"Marshalled Kagenti Agent CR as JSON string (required)"` + // Namespace is the Kubernetes namespace to deploy to + Namespace string `json:"namespace,omitempty" jsonschema:"Kubernetes namespace to deploy to (default: default)"` + // Replicas is the number of pod replicas + Replicas int64 `json:"replicas,omitempty" jsonschema:"Number of pod replicas (default: 1)"` +} + +// DeployKagentiOutput defines the output of deploying an agent. +type DeployKagentiOutput struct { + AgentName string `json:"agent_name,omitempty" jsonschema:"Name of the created/updated Agent CR"` + Namespace string `json:"namespace,omitempty" jsonschema:"Namespace where the agent was deployed"` + Created bool `json:"created" jsonschema:"True if created, false if updated"` + ErrorMessage string `json:"error_message,omitempty" jsonschema:"Error message if deployment failed"` +} + +// DeployKagenti deploys a Kagenti Agent CR to Kubernetes. +// The Agent CR should be provided as a marshalled JSON string. +func DeployKagenti(ctx context.Context, _ *mcp.CallToolRequest, input DeployKagentiInput) ( + *mcp.CallToolResult, + DeployKagentiOutput, + error, +) { + // Validate input + if input.AgentJSON == "" { + return nil, DeployKagentiOutput{ + ErrorMessage: "agent_json is required", + }, nil + } + + // Unmarshal JSON into unstructured object + var obj unstructured.Unstructured + if err := json.Unmarshal([]byte(input.AgentJSON), &obj.Object); err != nil { + return nil, DeployKagentiOutput{ + ErrorMessage: fmt.Sprintf("Failed to unmarshal Agent JSON: %v", err), + }, nil + } + + // Validate it's a Kagenti Agent + gvk := obj.GroupVersionKind() + if gvk.Group != KagentiAPIGroup || gvk.Version != KagentiAPIVersion || gvk.Kind != "Agent" { + return nil, DeployKagentiOutput{ + ErrorMessage: fmt.Sprintf("Invalid resource type: expected %s/%s/Agent, got %s/%s/%s", + KagentiAPIGroup, KagentiAPIVersion, gvk.Group, gvk.Version, gvk.Kind), + }, nil + } + + // Determine namespace (default to "default") + namespace := input.Namespace + if namespace == "" { + namespace = "default" + } + + // Determine replicas (default to 1) + replicas := input.Replicas + if replicas <= 0 { + replicas = 1 + } + + // Set namespace on the object + obj.SetNamespace(namespace) + + // Set replicas in spec + if err := unstructured.SetNestedField(obj.Object, replicas, "spec", "replicas"); err != nil { + return nil, DeployKagentiOutput{ + ErrorMessage: fmt.Sprintf("Failed to set replicas: %v", err), + }, nil + } + + agentName := obj.GetName() + if agentName == "" { + return nil, DeployKagentiOutput{ + ErrorMessage: "Agent CR must have a name", + }, nil + } + + // Apply to Kubernetes + created, err := applyUnstructured(ctx, &obj, namespace) + if err != nil { + return nil, DeployKagentiOutput{ + ErrorMessage: fmt.Sprintf("Failed to apply Agent CR to Kubernetes: %v", err), + }, nil + } + + return nil, DeployKagentiOutput{ + AgentName: agentName, + Namespace: namespace, + Created: created, + }, nil +} + +// applyUnstructured applies an unstructured object to Kubernetes. +// Returns true if created, false if updated. +func applyUnstructured(ctx context.Context, obj *unstructured.Unstructured, namespace string) (bool, error) { + // Get Kubernetes client + k8sClient, err := getK8sClient() + if err != nil { + return false, fmt.Errorf("failed to create Kubernetes client: %w", err) + } + + // Define the GVR for Agent resources + gvr := schema.GroupVersionResource{ + Group: KagentiAPIGroup, + Version: KagentiAPIVersion, + Resource: KagentiAgentResource, + } + + // Try to get existing resource first + existing, err := k8sClient.Resource(gvr).Namespace(namespace).Get(ctx, obj.GetName(), metav1.GetOptions{}) + if err == nil { + // Resource exists, update it + obj.SetResourceVersion(existing.GetResourceVersion()) + _, err = k8sClient.Resource(gvr).Namespace(namespace).Update(ctx, obj, metav1.UpdateOptions{}) + if err != nil { + return false, fmt.Errorf("failed to update Agent CR: %w", err) + } + return false, nil + } + + // Resource doesn't exist, create it + _, err = k8sClient.Resource(gvr).Namespace(namespace).Create(ctx, obj, metav1.CreateOptions{}) + if err != nil { + return false, fmt.Errorf("failed to create Agent CR: %w", err) + } + return true, nil +} + +// getK8sClient creates a dynamic Kubernetes client. +func getK8sClient() (dynamic.Interface, error) { + // Try in-cluster config first + config, err := rest.InClusterConfig() + if err != nil { + // Fall back to kubeconfig + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + configOverrides := &clientcmd.ConfigOverrides{} + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + + config, err = kubeConfig.ClientConfig() + if err != nil { + return nil, fmt.Errorf("failed to load kubeconfig: %w", err) + } + } + + // Create dynamic client + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create dynamic client: %w", err) + } + + return dynamicClient, nil +} diff --git a/mcp/tools/deploy_kagenti_test.go b/mcp/tools/deploy_kagenti_test.go new file mode 100644 index 000000000..f4b80a253 --- /dev/null +++ b/mcp/tools/deploy_kagenti_test.go @@ -0,0 +1,206 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package tools + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeployKagenti(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("fails when agent_json is empty", func(t *testing.T) { + t.Parallel() + + input := DeployKagentiInput{ + AgentJSON: "", + Namespace: "default", + } + + _, output, err := DeployKagenti(ctx, nil, input) + + require.NoError(t, err) + assert.Contains(t, output.ErrorMessage, "agent_json is required") + assert.Empty(t, output.AgentName) + }) + + t.Run("fails with invalid JSON", func(t *testing.T) { + t.Parallel() + + input := DeployKagentiInput{ + AgentJSON: `{invalid json}`, + Namespace: "default", + } + + _, output, err := DeployKagenti(ctx, nil, input) + + require.NoError(t, err) + assert.Contains(t, output.ErrorMessage, "Failed to unmarshal Agent JSON") + assert.Empty(t, output.AgentName) + }) + + t.Run("fails with wrong apiVersion", func(t *testing.T) { + t.Parallel() + + agentJSON := `{ + "apiVersion": "wrong.api/v1", + "kind": "Agent", + "metadata": { + "name": "test-agent" + } + }` + + input := DeployKagentiInput{ + AgentJSON: agentJSON, + Namespace: "default", + } + + _, output, err := DeployKagenti(ctx, nil, input) + + require.NoError(t, err) + assert.Contains(t, output.ErrorMessage, "Invalid resource type") + assert.Contains(t, output.ErrorMessage, "wrong.api/v1") + assert.Empty(t, output.AgentName) + }) + + t.Run("fails with wrong kind", func(t *testing.T) { + t.Parallel() + + agentJSON := `{ + "apiVersion": "agent.kagenti.dev/v1alpha1", + "kind": "WrongKind", + "metadata": { + "name": "test-agent" + } + }` + + input := DeployKagentiInput{ + AgentJSON: agentJSON, + Namespace: "default", + } + + _, output, err := DeployKagenti(ctx, nil, input) + + require.NoError(t, err) + assert.Contains(t, output.ErrorMessage, "Invalid resource type") + assert.Contains(t, output.ErrorMessage, "WrongKind") + assert.Empty(t, output.AgentName) + }) + + t.Run("fails when agent name is missing", func(t *testing.T) { + t.Parallel() + + agentJSON := `{ + "apiVersion": "agent.kagenti.dev/v1alpha1", + "kind": "Agent", + "metadata": {} + }` + + input := DeployKagentiInput{ + AgentJSON: agentJSON, + Namespace: "default", + } + + _, output, err := DeployKagenti(ctx, nil, input) + + require.NoError(t, err) + assert.Contains(t, output.ErrorMessage, "Agent CR must have a name") + assert.Empty(t, output.AgentName) + }) + + t.Run("defaults namespace to 'default' when not provided", func(t *testing.T) { + t.Parallel() + + agentJSON := `{ + "apiVersion": "agent.kagenti.dev/v1alpha1", + "kind": "Agent", + "metadata": { + "name": "test-agent" + }, + "spec": {} + }` + + input := DeployKagentiInput{ + AgentJSON: agentJSON, + Namespace: "", // Empty - should default to "default" + } + + _, output, err := DeployKagenti(ctx, nil, input) + + require.NoError(t, err) + // Will fail on K8s connection, but namespace should be set correctly + if output.ErrorMessage != "" { + assert.Contains(t, output.ErrorMessage, "Kubernetes") + } + // If it got past validation, namespace would be "default" + if output.Namespace != "" { + assert.Equal(t, "default", output.Namespace) + } + }) + + t.Run("defaults replicas to 1 when not provided", func(t *testing.T) { + t.Parallel() + + agentJSON := `{ + "apiVersion": "agent.kagenti.dev/v1alpha1", + "kind": "Agent", + "metadata": { + "name": "test-agent" + }, + "spec": {} + }` + + input := DeployKagentiInput{ + AgentJSON: agentJSON, + Namespace: "test-ns", + Replicas: 0, // Should default to 1 + } + + _, output, err := DeployKagenti(ctx, nil, input) + + require.NoError(t, err) + // Will fail on K8s connection, but that's expected without a cluster + // The important thing is that it passed validation + if output.ErrorMessage != "" { + assert.Contains(t, output.ErrorMessage, "Kubernetes") + } + }) + + t.Run("uses provided namespace over CR namespace", func(t *testing.T) { + t.Parallel() + + agentJSON := `{ + "apiVersion": "agent.kagenti.dev/v1alpha1", + "kind": "Agent", + "metadata": { + "name": "test-agent", + "namespace": "cr-namespace" + }, + "spec": {} + }` + + input := DeployKagentiInput{ + AgentJSON: agentJSON, + Namespace: "input-namespace", // Should take precedence + } + + _, output, err := DeployKagenti(ctx, nil, input) + + require.NoError(t, err) + // Will fail on K8s connection, but namespace should be from input + if output.ErrorMessage != "" { + assert.Contains(t, output.ErrorMessage, "Kubernetes") + } + if output.Namespace != "" { + assert.Equal(t, "input-namespace", output.Namespace) + } + }) +} + diff --git a/mcp/tools/export_record.go b/mcp/tools/export_record.go index 84ce8708e..da6f49f5e 100644 --- a/mcp/tools/export_record.go +++ b/mcp/tools/export_record.go @@ -31,6 +31,7 @@ type ExportRecordOutput struct { // Currently supported formats: // - "a2a": Agent-to-Agent (A2A) format. // - "ghcopilot": GitHub Copilot MCP configuration format. +// - "kagenti": Kagenti Agent Spec format. func ExportRecord(ctx context.Context, _ *mcp.CallToolRequest, input ExportRecordInput) ( *mcp.CallToolResult, ExportRecordOutput, @@ -94,9 +95,24 @@ func ExportRecord(ctx context.Context, _ *mcp.CallToolRequest, input ExportRecor }, nil } + case "kagenti": + kagentiAgentSpec, err := translator.RecordToKagentiAgentSpec(&recordStruct) + if err != nil { + return nil, ExportRecordOutput{ + ErrorMessage: fmt.Sprintf("Failed to export to Kagenti Agent Spec format: %v", err), + }, nil + } + // Use regular JSON marshaling since KagentiAgentSpec is not a protobuf message + exportedJSON, err = json.MarshalIndent(kagentiAgentSpec, "", " ") + if err != nil { + return nil, ExportRecordOutput{ + ErrorMessage: fmt.Sprintf("Failed to marshal Kagenti Agent Spec data to JSON: %v", err), + }, nil + } + default: return nil, ExportRecordOutput{ - ErrorMessage: fmt.Sprintf("Unsupported target format: %s. Supported formats: a2a, ghcopilot", input.TargetFormat), + ErrorMessage: fmt.Sprintf("Unsupported target format: %s. Supported formats: a2a, ghcopilot, kagenti", input.TargetFormat), }, nil } diff --git a/mcp/tools/export_record_test.go b/mcp/tools/export_record_test.go index cf398aab3..a6499b8c4 100644 --- a/mcp/tools/export_record_test.go +++ b/mcp/tools/export_record_test.go @@ -135,4 +135,34 @@ func TestExportRecord(t *testing.T) { assert.Contains(t, output.ErrorMessage, "Failed to export to A2A format") } }) + + t.Run("exports record to Kagenti format", func(t *testing.T) { + t.Parallel() + + // Note: This test verifies that the Kagenti export path is invoked. + // Actual translation success depends on the record having the required data, + // which is beyond the scope of this unit test. + + // Sample OASF record JSON + recordJSON := `{ + "schema_version": "0.8.0", + "name": "test-agent", + "version": "1.0.0", + "description": "A test agent" + }` + + input := ExportRecordInput{ + RecordJSON: recordJSON, + TargetFormat: "kagenti", + } + + _, output, err := ExportRecord(ctx, nil, input) + + require.NoError(t, err) + // The export may fail if the record doesn't have the required data, + // which is expected. The important part is that it attempts the export. + if output.ErrorMessage != "" { + assert.Contains(t, output.ErrorMessage, "Failed to export to Kagenti Agent Spec format") + } + }) } diff --git a/server/Dockerfile b/server/Dockerfile index 1f381c265..efd14cc8a 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -15,7 +15,7 @@ RUN --mount=type=cache,id=${TARGETPLATFORM}-apt,target=/var/cache/apt,sharing=lo gcc \ libc6-dev -WORKDIR /build/server +WORKDIR /build/dir/server RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ diff --git a/server/go.mod b/server/go.mod index 0c3249d52..733f1e2d6 100644 --- a/server/go.mod +++ b/server/go.mod @@ -5,6 +5,7 @@ go 1.25.2 replace ( github.com/agntcy/dir/api => ../api github.com/agntcy/dir/utils => ../utils + github.com/agntcy/oasf-sdk/pkg => ../../oasf-sdk/pkg ) require (