Skip to content

Commit 91d9bda

Browse files
Merge pull request #3 from ShahryarShabani/feat/confluence-integration
feat: Add Confluence toolset for creating pages
2 parents f2c5c6d + 126aecd commit 91d9bda

File tree

11 files changed

+295
-13
lines changed

11 files changed

+295
-13
lines changed

cmd/kubernetes-mcp-server/main_test.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,28 @@ package main
22

33
import (
44
"os"
5+
"testing"
6+
7+
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
8+
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/confluence"
9+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
10+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
11+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
512
)
613

14+
func TestMain(m *testing.M) {
15+
// The blank imports above will register the core, config, and helm toolsets.
16+
// We need to manually register the confluence toolset as it requires configuration.
17+
// For this test, we can register a disabled version.
18+
confluenceToolset, _ := confluence.NewToolset(nil)
19+
toolsets.Register(confluenceToolset)
20+
os.Exit(m.Run())
21+
}
22+
723
func Example_version() {
824
oldArgs := os.Args
925
defer func() { os.Args = oldArgs }()
1026
os.Args = []string{"kubernetes-mcp-server", "--version"}
1127
main()
1228
// Output: 0.0.0
13-
}
29+
}

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require (
1414
github.com/spf13/cobra v1.10.1
1515
github.com/spf13/pflag v1.0.10
1616
github.com/stretchr/testify v1.11.1
17+
github.com/virtomize/confluence-go-api v1.5.1
1718
golang.org/x/net v0.44.0
1819
golang.org/x/oauth2 v0.31.0
1920
golang.org/x/sync v0.17.0
@@ -87,6 +88,7 @@ require (
8788
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
8889
github.com/lib/pq v1.10.9 // indirect
8990
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
91+
github.com/magefile/mage v1.14.0 // indirect
9092
github.com/mailru/easyjson v0.9.0 // indirect
9193
github.com/mattn/go-colorable v0.1.14 // indirect
9294
github.com/mattn/go-isatty v0.0.20 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
185185
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
186186
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
187187
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
188+
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
189+
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
188190
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
189191
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
190192
github.com/mark3labs/mcp-go v0.40.0 h1:M0oqK412OHBKut9JwXSsj4KanSmEKpzoW8TcxoPOkAU=
@@ -288,6 +290,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
288290
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
289291
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
290292
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
293+
github.com/virtomize/confluence-go-api v1.5.1 h1:/xgL/XFB0rcTp8xWw41wpWYTQ50X4BBx05jy+ZlB25A=
294+
github.com/virtomize/confluence-go-api v1.5.1/go.mod h1:a96WPcok5g+7l5LC/ztcrp4cLmrIA1DHxxZSv/iqvsQ=
291295
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
292296
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
293297
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=

pkg/config/config.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
// StaticConfig is the configuration for the server.
1010
// It allows to configure server specific settings and tools to be enabled or disabled.
1111
type StaticConfig struct {
12+
Confluence *ConfluenceConfig `toml:"confluence,omitempty"`
13+
1214
DeniedResources []GroupVersionKind `toml:"denied_resources"`
1315

1416
LogLevel int `toml:"log_level,omitempty"`
@@ -51,10 +53,17 @@ type StaticConfig struct {
5153
ServerURL string `toml:"server_url,omitempty"`
5254
}
5355

56+
// ConfluenceConfig is the configuration for the Confluence toolset.
57+
type ConfluenceConfig struct {
58+
URL string `toml:"url"`
59+
Username string `toml:"username"`
60+
Token string `toml:"token"`
61+
}
62+
5463
func Default() *StaticConfig {
5564
return &StaticConfig{
5665
ListOutput: "table",
57-
Toolsets: []string{"core", "config", "helm"},
66+
Toolsets: []string{"core", "config", "helm", "confluence"},
5867
}
5968
}
6069

pkg/config/config_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ func (s *ConfigSuite) TestReadConfigValidPreservesDefaultsForMissingFields() {
152152
s.Equalf("table", config.ListOutput, "Expected ListOutput to be table, got %s", config.ListOutput)
153153
})
154154
s.Run("toolsets defaulted correctly", func() {
155-
s.Require().Lenf(config.Toolsets, 3, "Expected 3 toolsets, got %d", len(config.Toolsets))
156-
for _, toolset := range []string{"core", "config", "helm"} {
155+
s.Require().Lenf(config.Toolsets, 4, "Expected 4 toolsets, got %d", len(config.Toolsets))
156+
for _, toolset := range []string{"core", "config", "helm", "confluence"} {
157157
s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
158158
}
159159
})

pkg/kubernetes-mcp-server/cmd/root_test.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,25 @@ import (
1010
"strings"
1111
"testing"
1212

13+
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
14+
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/confluence"
15+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
16+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
17+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
1318
"github.com/stretchr/testify/assert"
1419
"github.com/stretchr/testify/require"
1520
"k8s.io/cli-runtime/pkg/genericiooptions"
1621
)
1722

23+
func TestMain(m *testing.M) {
24+
// The blank imports above will register the core, config, and helm toolsets.
25+
// We need to manually register the confluence toolset as it requires configuration.
26+
// For this test, we can register a disabled version.
27+
confluenceToolset, _ := confluence.NewToolset(nil)
28+
toolsets.Register(confluenceToolset)
29+
os.Exit(m.Run())
30+
}
31+
1832
func captureOutput(f func() error) (string, error) {
1933
originalOut := os.Stdout
2034
defer func() {
@@ -137,15 +151,17 @@ func TestToolsets(t *testing.T) {
137151
rootCmd := NewMCPServer(ioStreams)
138152
rootCmd.SetArgs([]string{"--help"})
139153
o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout
140-
if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm).") {
141-
t.Fatalf("Expected all available toolsets, got %s %v", o, err)
142-
}
154+
assert.NoError(t, err)
155+
assert.Contains(t, o, "config")
156+
assert.Contains(t, o, "core")
157+
assert.Contains(t, o, "helm")
158+
assert.Contains(t, o, "confluence")
143159
})
144160
t.Run("default", func(t *testing.T) {
145161
ioStreams, out := testStream()
146162
rootCmd := NewMCPServer(ioStreams)
147163
rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
148-
if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolsets: core, config, helm") {
164+
if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolsets: core, config, helm, confluence") {
149165
t.Fatalf("Expected toolsets 'full', got %s %v", out, err)
150166
}
151167
})
@@ -275,4 +291,4 @@ func TestStdioLogging(t *testing.T) {
275291
require.NoErrorf(t, err, "Expected no error executing command, got %v", err)
276292
assert.Containsf(t, out.String(), "Starting kubernetes-mcp-server", "Expected klog output, got %s", out.String())
277293
})
278-
}
294+
}

pkg/kubernetes/search.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,4 @@ func contains(slice []string, s string) bool {
178178
}
179179
}
180180
return false
181-
}
181+
}

pkg/mcp/common_test.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ import (
4646
"github.com/containers/kubernetes-mcp-server/internal/test"
4747
"github.com/containers/kubernetes-mcp-server/pkg/config"
4848
"github.com/containers/kubernetes-mcp-server/pkg/output"
49+
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
50+
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/confluence"
51+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
52+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
53+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
4954
)
5055

5156
// envTest has an expensive setup, so we only want to do it once per entire test run.
@@ -54,6 +59,12 @@ var envTestRestConfig *rest.Config
5459
var envTestUser = envtest.User{Name: "test-user", Groups: []string{"test:users"}}
5560

5661
func TestMain(m *testing.M) {
62+
// The blank imports above will register the core, config, and helm toolsets.
63+
// We need to manually register the confluence toolset as it requires configuration.
64+
// For this test, we can register a disabled version.
65+
confluenceToolset, _ := confluence.NewToolset(nil)
66+
toolsets.Register(confluenceToolset)
67+
5768
// Set up
5869
_ = os.Setenv("KUBECONFIG", "/dev/null") // Avoid interference from existing kubeconfig
5970
_ = os.Setenv("KUBERNETES_SERVICE_HOST", "") // Avoid interference from in-cluster config
@@ -448,4 +459,4 @@ func (s *BaseMcpSuite) InitMcpClient() {
448459
s.mcpServer, err = NewServer(Configuration{StaticConfig: s.Cfg})
449460
s.Require().NoError(err, "Expected no error creating MCP server")
450461
s.McpClient = test.NewMcpClient(s.T(), s.mcpServer.ServeHTTP(nil))
451-
}
462+
}

pkg/mcp/mcp.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
1919
"github.com/containers/kubernetes-mcp-server/pkg/output"
2020
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
21+
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/confluence"
2122
"github.com/containers/kubernetes-mcp-server/pkg/version"
2223
)
2324

@@ -33,8 +34,22 @@ type Configuration struct {
3334

3435
func (c *Configuration) Toolsets() []api.Toolset {
3536
if c.toolsets == nil {
36-
for _, toolset := range c.StaticConfig.Toolsets {
37-
c.toolsets = append(c.toolsets, toolsets.ToolsetFromString(toolset))
37+
for _, toolsetName := range c.StaticConfig.Toolsets {
38+
var toolset api.Toolset
39+
if toolsetName == "confluence" {
40+
var err error
41+
toolset, err = confluence.NewToolset(c.Confluence)
42+
if err != nil {
43+
klog.Warningf("failed to initialize confluence toolset: %v", err)
44+
continue
45+
}
46+
} else {
47+
toolset = toolsets.ToolsetFromString(toolsetName)
48+
}
49+
50+
if toolset != nil {
51+
c.toolsets = append(c.toolsets, toolset)
52+
}
3853
}
3954
}
4055
return c.toolsets

pkg/toolsets/confluence/toolset.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package confluence
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/google/jsonschema-go/jsonschema"
7+
goconfluence "github.com/virtomize/confluence-go-api"
8+
"k8s.io/utils/ptr"
9+
10+
"github.com/containers/kubernetes-mcp-server/pkg/api"
11+
"github.com/containers/kubernetes-mcp-server/pkg/config"
12+
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
13+
)
14+
15+
// NewToolset returns a new toolset for Confluence.
16+
func NewToolset(cfg *config.ConfluenceConfig) (api.Toolset, error) {
17+
if cfg == nil || cfg.URL == "" {
18+
return &disabledToolset{}, nil
19+
}
20+
21+
confluenceAPI, err := goconfluence.NewAPI(cfg.URL, cfg.Username, cfg.Token)
22+
if err != nil {
23+
return nil, fmt.Errorf("failed to create confluence api: %w", err)
24+
}
25+
26+
return &confluenceToolset{api: confluenceAPI}, nil
27+
}
28+
29+
type confluenceToolset struct {
30+
api *goconfluence.API
31+
}
32+
33+
func (t *confluenceToolset) GetName() string {
34+
return "confluence"
35+
}
36+
37+
func (t *confluenceToolset) GetDescription() string {
38+
return "Tools for interacting with Confluence"
39+
}
40+
41+
func (t *confluenceToolset) GetTools(_ kubernetes.Openshift) []api.ServerTool {
42+
if t.api == nil {
43+
return nil
44+
}
45+
return []api.ServerTool{
46+
{
47+
Tool: api.Tool{
48+
Name: "confluence.createPage",
49+
Description: "Create a new page in Confluence.",
50+
InputSchema: &jsonschema.Schema{
51+
Type: "object",
52+
Properties: map[string]*jsonschema.Schema{
53+
"space_key": {
54+
Type: "string",
55+
Description: "The key of the space to create the page in.",
56+
},
57+
"title": {
58+
Type: "string",
59+
Description: "The title of the new page.",
60+
},
61+
"content": {
62+
Type: "string",
63+
Description: "The content of the new page in Confluence Storage Format (XHTML).",
64+
},
65+
"parent_id": {
66+
Type: "string",
67+
Description: "Optional ID of a parent page.",
68+
},
69+
},
70+
Required: []string{"space_key", "title", "content"},
71+
},
72+
Annotations: api.ToolAnnotations{
73+
DestructiveHint: ptr.To(true),
74+
},
75+
},
76+
Handler: createPageHandler(t.api),
77+
},
78+
}
79+
}
80+
81+
func createPageHandler(confluenceAPI *goconfluence.API) api.ToolHandlerFunc {
82+
return func(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
83+
spaceKey, _ := params.GetArguments()["space_key"].(string)
84+
title, _ := params.GetArguments()["title"].(string)
85+
content, _ := params.GetArguments()["content"].(string)
86+
parentID, _ := params.GetArguments()["parent_id"].(string)
87+
88+
pageContent := &goconfluence.Content{
89+
Type: "page",
90+
Title: title,
91+
Space: &goconfluence.Space{Key: spaceKey},
92+
Body: goconfluence.Body{
93+
Storage: goconfluence.Storage{
94+
Value: content,
95+
Representation: "storage",
96+
},
97+
},
98+
}
99+
100+
if parentID != "" {
101+
pageContent.Ancestors = []goconfluence.Ancestor{
102+
{ID: parentID},
103+
}
104+
}
105+
106+
createdPage, err := confluenceAPI.CreateContent(pageContent)
107+
if err != nil {
108+
return api.NewToolCallResult("", fmt.Errorf("failed to create page: %w", err)), nil
109+
}
110+
111+
return api.NewToolCallResult(fmt.Sprintf("Page created successfully with ID %s. View it at: %s", createdPage.ID, createdPage.Links.WebUI), nil), nil
112+
}
113+
}
114+
115+
type disabledToolset struct{}
116+
117+
func (t *disabledToolset) GetName() string {
118+
return "confluence"
119+
}
120+
121+
func (t *disabledToolset) GetDescription() string {
122+
return "Confluence toolset is disabled. Please configure it in the server settings."
123+
}
124+
125+
func (t *disabledToolset) GetTools(_ kubernetes.Openshift) []api.ServerTool {
126+
return nil
127+
}

0 commit comments

Comments
 (0)