Skip to content

Commit 3e2f3e8

Browse files
authored
docker: base impl for docker proxy (#11)
* docker: base impl for docker proxy * docker: use client proxy function * docker: use client-api-go@main * docker: remove unused server properties * docker: update README * docker: introduce unit tests * docker: update utils_test.go * docker: introduce support for query params and headers * docker: add integration tests * docker: update dep client-api-go@main * docs: update README
1 parent ce7aa53 commit 3e2f3e8

21 files changed

Lines changed: 1037 additions & 26 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Ever wished you could just ask Portainer what's going on?
44

5-
![portainer-mcp-demo](https://downloads.portainer.io/mcp-demo2.gif)
5+
![portainer-mcp-demo](https://downloads.portainer.io/mcp-demo3.gif)
66

77
## Overview
88

@@ -95,7 +95,7 @@ To enable read-only mode, add the `-read-only` flag to your command arguments:
9595
When using read-only mode:
9696
- Only read tools (list, get) will be available to the AI model
9797
- All write tools (create, update, delete) are not loaded
98-
98+
- The Docker proxy requests tool is not loaded
9999

100100
# Portainer Version Support
101101

@@ -147,3 +147,5 @@ The following table lists the currently (latest version) supported operations th
147147
| | ListUsers | List all available users |
148148
| | UpdateUser | Update an existing user |
149149
| | GetSettings | Get the settings of the Portainer instance |
150+
| **Docker** |
151+
| | DockerProxy | Proxy ANY Docker API requests |

cmd/portainer-mcp/mcp.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ func main() {
6060
server.AddUserFeatures()
6161
server.AddTeamFeatures()
6262
server.AddAccessGroupFeatures()
63+
server.AddDockerProxyFeatures()
6364

6465
err = server.Start()
6566
if err != nil {

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ module github.com/portainer/portainer-mcp
33
go 1.23.5
44

55
require (
6+
github.com/docker/docker v28.0.1+incompatible
67
github.com/docker/go-connections v0.5.0
78
github.com/mark3labs/mcp-go v0.15.0
8-
github.com/portainer/client-api-go/v2 v2.28.1
9+
github.com/portainer/client-api-go/v2 v2.28.2-0.20250414223238-5d44497603b3
910
github.com/rs/zerolog v1.34.0
1011
github.com/stretchr/testify v1.10.0
1112
github.com/testcontainers/testcontainers-go v0.36.0
@@ -24,7 +25,6 @@ require (
2425
github.com/cpuguy83/dockercfg v0.3.2 // indirect
2526
github.com/davecgh/go-spew v1.1.1 // indirect
2627
github.com/distribution/reference v0.6.0 // indirect
27-
github.com/docker/docker v28.0.1+incompatible // indirect
2828
github.com/docker/go-units v0.5.0 // indirect
2929
github.com/ebitengine/purego v0.8.2 // indirect
3030
github.com/felixge/httpsnoop v1.0.4 // indirect

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,10 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
124124
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
125125
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
126126
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
127-
github.com/portainer/client-api-go/v2 v2.27.2-0.20250407070027-8601a4c528d0 h1:yD2Z1fKwhrWER3pBLPfrqahLwgKpJ/QJFqdy6UbmhAs=
128-
github.com/portainer/client-api-go/v2 v2.27.2-0.20250407070027-8601a4c528d0/go.mod h1:L0VSNt2JOgUpbFGmGH8IkbjgVaCZiRC75+COX424ulw=
129-
github.com/portainer/client-api-go/v2 v2.28.1 h1:XeN3XtEyedap9/N6mq3RjuH0/19U1ZQo21wgZIqcBDM=
130-
github.com/portainer/client-api-go/v2 v2.28.1/go.mod h1:L0VSNt2JOgUpbFGmGH8IkbjgVaCZiRC75+COX424ulw=
127+
github.com/portainer/client-api-go/v2 v2.28.2-0.20250414021059-9d657d985df9 h1:UK2T+dFkmTpEkPGXnZwMqTjhqeJUoC0M0rCfrMYwPlM=
128+
github.com/portainer/client-api-go/v2 v2.28.2-0.20250414021059-9d657d985df9/go.mod h1:L0VSNt2JOgUpbFGmGH8IkbjgVaCZiRC75+COX424ulw=
129+
github.com/portainer/client-api-go/v2 v2.28.2-0.20250414223238-5d44497603b3 h1:FqpTUyLJ11s/QlDpD1A8zzWoJphblnw/HUJqGSKe5CU=
130+
github.com/portainer/client-api-go/v2 v2.28.2-0.20250414223238-5d44497603b3/go.mod h1:L0VSNt2JOgUpbFGmGH8IkbjgVaCZiRC75+COX424ulw=
131131
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
132132
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
133133
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=

internal/mcp/docker.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"strings"
8+
9+
"github.com/mark3labs/mcp-go/mcp"
10+
"github.com/mark3labs/mcp-go/server"
11+
"github.com/portainer/portainer-mcp/pkg/portainer/models"
12+
"github.com/portainer/portainer-mcp/pkg/toolgen"
13+
)
14+
15+
func (s *PortainerMCPServer) AddDockerProxyFeatures() {
16+
if !s.readOnly {
17+
s.addToolIfExists(ToolDockerProxy, s.HandleDockerProxy())
18+
}
19+
}
20+
21+
func (s *PortainerMCPServer) HandleDockerProxy() server.ToolHandlerFunc {
22+
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
23+
parser := toolgen.NewParameterParser(request)
24+
25+
environmentId, err := parser.GetInt("environmentId", true)
26+
if err != nil {
27+
return nil, err
28+
}
29+
30+
method, err := parser.GetString("method", true)
31+
if err != nil {
32+
return nil, err
33+
}
34+
if !isValidHTTPMethod(method) {
35+
return nil, fmt.Errorf("invalid method: %s", method)
36+
}
37+
38+
dockerAPIPath, err := parser.GetString("dockerAPIPath", true)
39+
if err != nil {
40+
return nil, err
41+
}
42+
if !strings.HasPrefix(dockerAPIPath, "/") {
43+
return nil, fmt.Errorf("dockerAPIPath must start with a leading slash")
44+
}
45+
46+
queryParams, err := parser.GetArrayOfObjects("queryParams", false)
47+
if err != nil {
48+
return nil, err
49+
}
50+
queryParamsMap, err := parseKeyValueMap(queryParams)
51+
if err != nil {
52+
return nil, fmt.Errorf("invalid query params: %w", err)
53+
}
54+
55+
headers, err := parser.GetArrayOfObjects("headers", false)
56+
if err != nil {
57+
return nil, err
58+
}
59+
headersMap, err := parseKeyValueMap(headers)
60+
if err != nil {
61+
return nil, fmt.Errorf("invalid headers: %w", err)
62+
}
63+
64+
body, err := parser.GetString("body", false)
65+
if err != nil {
66+
return nil, err
67+
}
68+
69+
opts := models.DockerProxyRequestOptions{
70+
EnvironmentID: environmentId,
71+
Path: dockerAPIPath,
72+
Method: method,
73+
QueryParams: queryParamsMap,
74+
Headers: headersMap,
75+
}
76+
77+
if body != "" {
78+
opts.Body = strings.NewReader(body)
79+
}
80+
81+
response, err := s.cli.ProxyDockerRequest(opts)
82+
if err != nil {
83+
return nil, fmt.Errorf("failed to send Docker API request: %w", err)
84+
}
85+
86+
responseBody, err := io.ReadAll(response.Body)
87+
if err != nil {
88+
return nil, fmt.Errorf("failed to read Docker API response: %w", err)
89+
}
90+
91+
return mcp.NewToolResultText(string(responseBody)), nil
92+
}
93+
}

0 commit comments

Comments
 (0)