Skip to content

Commit 1926a69

Browse files
authored
Merge pull request #467 from controlplaneio-fluxcd/mcp-install-flux
mcp: Add `install_flux_instance` tool to MCP Server
2 parents e30e41b + 83ec046 commit 1926a69

File tree

7 files changed

+378
-206
lines changed

7 files changed

+378
-206
lines changed

cmd/cli/install.go

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,8 @@ func installCmdRun(cmd *cobra.Command, args []string) error {
196196
} else {
197197
rootCmd.Println(`◎`, "Installing Flux Operator in flux-system namespace...")
198198
}
199-
cs, err := installer.ApplyOperator(ctx, objects)
199+
multitenant := instance.Spec.Cluster != nil && instance.Spec.Cluster.Multitenant
200+
cs, err := installer.ApplyOperator(ctx, objects, multitenant)
200201
if err != nil {
201202
return err
202203
}
@@ -261,7 +262,7 @@ func installCmdRun(cmd *cobra.Command, args []string) error {
261262

262263
if installArgs.autoUpdate {
263264
rootCmd.Println(`◎`, "Configuring automatic updates...")
264-
cs, err := installer.ApplyAutoUpdate(ctx, installArgs.clusterMultitenant)
265+
cs, err := installer.ApplyAutoUpdate(ctx, multitenant)
265266
if err != nil {
266267
return err
267268
}
@@ -327,11 +328,6 @@ func makeFluxInstance(ctx context.Context) (instance *fluxcdv1.FluxInstance, art
327328
if instance.Spec.Distribution.Artifact != "" {
328329
artifactURL = instance.Spec.Distribution.Artifact
329330
}
330-
331-
// Use multitenant setting from file if present
332-
if instance.Spec.Cluster != nil && instance.Spec.Cluster.Multitenant {
333-
installArgs.clusterMultitenant = true
334-
}
335331
} else {
336332
// No file provided, build from flags
337333
instance = &fluxcdv1.FluxInstance{
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Copyright 2025 Stefan Prodan.
2+
// SPDX-License-Identifier: AGPL-3.0
3+
4+
package toolbox
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"fmt"
10+
"strings"
11+
"time"
12+
13+
ssautil "github.com/fluxcd/pkg/ssa/utils"
14+
"github.com/google/go-containerregistry/pkg/authn"
15+
"github.com/modelcontextprotocol/go-sdk/mcp"
16+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
17+
"k8s.io/client-go/rest"
18+
"sigs.k8s.io/yaml"
19+
20+
fluxcdv1 "github.com/controlplaneio-fluxcd/flux-operator/api/v1"
21+
"github.com/controlplaneio-fluxcd/flux-operator/cmd/mcp/auth"
22+
"github.com/controlplaneio-fluxcd/flux-operator/internal/install"
23+
)
24+
25+
const (
26+
// ToolInstallFluxInstance is the name of the install_flux_instance tool.
27+
ToolInstallFluxInstance = "install_flux_instance"
28+
)
29+
30+
func init() {
31+
systemTools[ToolInstallFluxInstance] = systemTool{
32+
readOnly: false,
33+
inCluster: true,
34+
}
35+
}
36+
37+
// installFluxInstanceInput defines the input parameters for installing Flux instance.
38+
type installFluxInstanceInput struct {
39+
InstanceURL string `json:"instance_url" jsonschema:"The URL pointing to the Flux Instance manifest file."`
40+
Timeout string `json:"timeout,omitempty" jsonschema:"The installation timeout. Default is 5m."`
41+
}
42+
43+
// HandleInstallFluxInstance is the handler function for the install_flux_instance tool.
44+
func (m *Manager) HandleInstallFluxInstance(ctx context.Context, request *mcp.CallToolRequest, input installFluxInstanceInput) (*mcp.CallToolResult, any, error) {
45+
if err := auth.CheckScopes(ctx, getScopeNames(ToolInstallFluxInstance, m.readOnly)); err != nil {
46+
return NewToolResultError(err.Error())
47+
}
48+
now := time.Now()
49+
if input.InstanceURL == "" {
50+
return NewToolResultError("The instance URL cannot be empty")
51+
}
52+
53+
timeoutStr := input.Timeout
54+
if timeoutStr == "" {
55+
timeoutStr = "5m"
56+
}
57+
timeout, err := time.ParseDuration(timeoutStr)
58+
if err != nil {
59+
return NewToolResultError("The timeout is not a valid duration")
60+
}
61+
if timeout < 5*time.Minute {
62+
timeout = 5 * time.Minute
63+
}
64+
waitTimeout := timeout - 30*time.Second
65+
66+
// TODO: stream logs back to the MCP client while the installation is in progress.
67+
installLog := strings.Builder{}
68+
69+
ctx, cancel := context.WithTimeout(ctx, timeout)
70+
defer cancel()
71+
72+
// Step 1: Download the Flux instance manifest and operator manifests
73+
74+
instance, err := m.fetchInstanceManifest(ctx, input.InstanceURL)
75+
if err != nil {
76+
return NewToolResultErrorFromErr("failed to fetch instance manifest", err)
77+
}
78+
79+
operatorObjects, err := m.fetchOperatorManifest(ctx, instance)
80+
if err != nil {
81+
return NewToolResultErrorFromErr("failed to fetch operator manifest", err)
82+
}
83+
installLog.WriteString(fmt.Sprintf("Artifact download completed in %s\n", time.Since(now).Round(time.Second)))
84+
85+
// Step 2: Create Kubernetes client with impersonation if needed
86+
87+
cfg, err := m.flags.ToRESTConfig()
88+
if err != nil {
89+
return NewToolResultErrorFromErr("loading kubeconfig failed", err)
90+
}
91+
92+
if sess := auth.FromContext(ctx); sess != nil {
93+
cfg.Impersonate = rest.ImpersonationConfig{
94+
UserName: sess.UserName,
95+
Groups: sess.Groups,
96+
}
97+
}
98+
99+
installer, err := install.NewInstaller(ctx, cfg)
100+
if err != nil {
101+
return NewToolResultErrorFromErr("failed to create installer", err)
102+
}
103+
104+
// Step 3: Install or upgrade the Flux Operator
105+
106+
isInstalled, err := installer.IsInstalled(ctx)
107+
if err != nil {
108+
return NewToolResultErrorFromErr("failed prerequisites", err)
109+
}
110+
if !isInstalled {
111+
installLog.WriteString("Installing Flux Operator...\n")
112+
} else {
113+
installLog.WriteString("Upgrading Flux Operator...\n")
114+
}
115+
multitenant := instance.Spec.Cluster != nil && instance.Spec.Cluster.Multitenant
116+
cs, err := installer.ApplyOperator(ctx, operatorObjects, multitenant)
117+
if err != nil {
118+
return NewToolResultErrorFromErr("failed to install the operator", err)
119+
}
120+
installLog.WriteString(cs.String())
121+
installLog.WriteString("\n")
122+
if err := installer.WaitFor(ctx, cs, waitTimeout); err != nil {
123+
return NewToolResultErrorFromErr("failed to wait for the operator", err)
124+
}
125+
installLog.WriteString("Flux Operator is ready.\n")
126+
127+
// Step 4: Install or upgrade the Flux instance
128+
129+
installLog.WriteString("Installing Flux Instance...\n")
130+
cs, err = installer.ApplyInstance(ctx, instance)
131+
if err != nil {
132+
return NewToolResultErrorFromErr("failed to install instance", err)
133+
}
134+
installLog.WriteString(cs.String())
135+
installLog.WriteString("\n")
136+
if err := installer.WaitFor(ctx, cs, waitTimeout-30*time.Second); err != nil {
137+
return NewToolResultErrorFromErr("failed to wait for the instance", err)
138+
}
139+
140+
// Step 5: Configure automatic updates
141+
142+
installLog.WriteString("Configuring automatic updates...\n")
143+
cs, err = installer.ApplyAutoUpdate(ctx, multitenant)
144+
if err != nil {
145+
return NewToolResultErrorFromErr("failed to configure automatic updates", err)
146+
}
147+
installLog.WriteString(cs.String())
148+
installLog.WriteString("\n")
149+
if err := installer.WaitFor(ctx, cs, waitTimeout-time.Minute); err != nil {
150+
return NewToolResultErrorFromErr("failed to wait for automatic updates", err)
151+
}
152+
installLog.WriteString(fmt.Sprintf("Installation completed in %s\n", time.Since(now).Round(time.Second)))
153+
154+
return NewToolResultText(installLog.String())
155+
}
156+
157+
// fetchInstanceManifest downloads and parses the FluxInstance manifest from the given URL.
158+
func (m *Manager) fetchInstanceManifest(ctx context.Context, instanceURL string) (*fluxcdv1.FluxInstance, error) {
159+
instance := &fluxcdv1.FluxInstance{}
160+
data, err := install.DownloadManifestFromURL(ctx, instanceURL, authn.DefaultKeychain)
161+
if err != nil {
162+
return nil, err
163+
}
164+
165+
if err := yaml.Unmarshal(data, instance); err != nil {
166+
return nil, fmt.Errorf("failed to parse Flux instance: %w", err)
167+
}
168+
169+
// Set namespace to flux-system
170+
instance.Namespace = install.DefaultNamespace
171+
172+
return instance, nil
173+
}
174+
175+
// fetchOperatorManifest downloads and parses the Flux Operator manifest from the distribution artifact.
176+
func (m *Manager) fetchOperatorManifest(ctx context.Context, instance *fluxcdv1.FluxInstance) ([]*unstructured.Unstructured, error) {
177+
artifactURL := install.DefaultArtifactURL
178+
if instance.Spec.Distribution.Artifact != "" {
179+
artifactURL = instance.Spec.Distribution.Artifact
180+
}
181+
182+
data, err := install.DownloadFileFromArtifact(
183+
ctx,
184+
artifactURL,
185+
"flux-operator/install.yaml",
186+
authn.DefaultKeychain,
187+
)
188+
if err != nil {
189+
return nil, fmt.Errorf("failed to pull distribution artifact: %w", err)
190+
}
191+
192+
objects, err := ssautil.ReadObjects(bytes.NewReader(data))
193+
if err != nil {
194+
return nil, fmt.Errorf("unable to parse flux-operator/install.yaml: %w", err)
195+
}
196+
197+
if len(objects) == 0 {
198+
return nil, fmt.Errorf("no Kubernetes objects found in flux-operator/install.yaml")
199+
}
200+
201+
return objects, nil
202+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright 2025 Stefan Prodan.
2+
// SPDX-License-Identifier: AGPL-3.0
3+
4+
package toolbox
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"testing"
10+
"time"
11+
12+
"github.com/modelcontextprotocol/go-sdk/mcp"
13+
. "github.com/onsi/gomega"
14+
cli "k8s.io/cli-runtime/pkg/genericclioptions"
15+
16+
"github.com/controlplaneio-fluxcd/flux-operator/cmd/mcp/k8s"
17+
)
18+
19+
func TestManager_HandleInstallFluxInstance(t *testing.T) {
20+
configFile := "testdata/kubeconfig.yaml"
21+
t.Setenv("KUBECONFIG", configFile)
22+
23+
m := &Manager{
24+
kubeconfig: k8s.NewKubeConfig(),
25+
flags: cli.NewConfigFlags(false),
26+
timeout: time.Second,
27+
}
28+
29+
request := &mcp.CallToolRequest{
30+
Params: &mcp.CallToolParamsRaw{
31+
Name: "install_flux_instance",
32+
},
33+
}
34+
35+
tests := []struct {
36+
testName string
37+
arguments map[string]any
38+
matchErr string
39+
}{
40+
{
41+
testName: "fails without instance_url",
42+
arguments: map[string]any{
43+
"instance_url": "",
44+
},
45+
matchErr: "The instance URL cannot be empty",
46+
},
47+
{
48+
testName: "fails with invalid timeout",
49+
arguments: map[string]any{
50+
"instance_url": "https://example.com/instance.yaml",
51+
"timeout": "invalid",
52+
},
53+
matchErr: "The timeout is not a valid duration",
54+
},
55+
{
56+
testName: "fails with invalid kubeconfig",
57+
arguments: map[string]any{
58+
"instance_url": "https://example.com/instance.yaml",
59+
},
60+
matchErr: "failed to fetch instance manifest",
61+
},
62+
}
63+
64+
for _, test := range tests {
65+
t.Run(test.testName, func(t *testing.T) {
66+
g := NewWithT(t)
67+
argsJSON, _ := json.Marshal(test.arguments)
68+
request.Params.Arguments = argsJSON
69+
70+
var input installFluxInstanceInput
71+
err := json.Unmarshal(request.Params.Arguments, &input)
72+
g.Expect(err).ToNot(HaveOccurred())
73+
result, content, err := m.HandleInstallFluxInstance(context.Background(), request, input)
74+
g.Expect(err).ToNot(HaveOccurred())
75+
textContent, ok := result.Content[0].(*mcp.TextContent)
76+
g.Expect(ok).To(BeTrue())
77+
78+
g.Expect(result.IsError).To(BeTrue())
79+
g.Expect(textContent.Text).To(ContainSubstring(test.matchErr))
80+
_ = content
81+
})
82+
}
83+
}

cmd/mcp/toolbox/manager.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ func NewManager(flags *cli.ConfigFlags, timeout time.Duration, maskSecrets bool,
5252

5353
// RegisterTools registers tools with the given server.
5454
func (m *Manager) RegisterTools(server *mcp.Server, inCluster bool) {
55+
if m.shouldRegisterTool(ToolInstallFluxInstance, inCluster) {
56+
mcp.AddTool(server,
57+
&mcp.Tool{
58+
Name: ToolInstallFluxInstance,
59+
Description: "This tool installs Flux Operator and a Flux instance on the cluster from a manifest URL.",
60+
},
61+
m.HandleInstallFluxInstance,
62+
)
63+
}
5564
if m.shouldRegisterTool(ToolGetFluxInstance, inCluster) {
5665
mcp.AddTool(server,
5766
&mcp.Tool{

cmd/mcp/toolbox/scopes.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ var scopesPerTool = map[string]toolScopes{
114114
ownScopeDescription: "Allow suspending the reconciliation of Flux resources.",
115115
extraScopes: []string{},
116116
},
117+
ToolInstallFluxInstance: {
118+
ownScopeDescription: "Allow installing Flux Operator and Flux instance.",
119+
extraScopes: []string{},
120+
},
117121
}
118122

119123
// getScopes returns the scopes that grant access to the given tool.

0 commit comments

Comments
 (0)