# Directory Structure ``` ├── .github │ ├── dependabot.yml │ └── workflows │ ├── build.yaml │ └── release.yaml ├── .gitignore ├── cmd │ └── podman-mcp-server │ ├── main_test.go │ └── main.go ├── go.mod ├── go.sum ├── LICENSE ├── Makefile ├── npm │ ├── podman-mcp-server │ │ ├── bin │ │ │ └── index.js │ │ └── package.json │ ├── podman-mcp-server-darwin-amd64 │ │ └── package.json │ ├── podman-mcp-server-darwin-arm64 │ │ └── package.json │ ├── podman-mcp-server-linux-amd64 │ │ └── package.json │ ├── podman-mcp-server-linux-arm64 │ │ └── package.json │ ├── podman-mcp-server-windows-amd64 │ │ └── package.json │ └── podman-mcp-server-windows-arm64 │ └── package.json ├── pkg │ ├── mcp │ │ ├── common_test.go │ │ ├── mcp_test.go │ │ ├── mcp.go │ │ ├── podman_container_test.go │ │ ├── podman_container.go │ │ ├── podman_image_test.go │ │ ├── podman_image.go │ │ ├── podman_network_test.go │ │ ├── podman_network.go │ │ ├── podman_volume_test.go │ │ └── podman_volume.go │ ├── podman │ │ ├── interface.go │ │ └── podman_cli.go │ ├── podman-mcp-server │ │ └── cmd │ │ ├── root_test.go │ │ └── root.go │ └── version │ └── version.go ├── python │ ├── podman_mcp_server │ │ ├── __init__.py │ │ ├── __main__.py │ │ └── podman_mcp_server.py │ ├── pyproject.toml │ └── README.md ├── README.md └── testdata └── podman └── main.go ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | .idea/ 2 | .docusaurus/ 3 | node_modules/ 4 | 5 | .npmrc 6 | /podman-mcp-server 7 | npm/podman-mcp-server/README.md 8 | npm/podman-mcp-server/LICENSE 9 | !npm/podman-mcp-server 10 | podman-mcp-server-darwin-amd64 11 | !npm/podman-mcp-server-darwin-amd64/ 12 | podman-mcp-server-darwin-arm64 13 | !npm/podman-mcp-server-darwin-arm64 14 | podman-mcp-server-linux-amd64 15 | !npm/podman-mcp-server-linux-amd64 16 | podman-mcp-server-linux-arm64 17 | !npm/podman-mcp-server-linux-arm64 18 | podman-mcp-server-windows-amd64.exe 19 | podman-mcp-server-windows-arm64.exe 20 | 21 | python/.venv/ 22 | python/build/ 23 | python/dist/ 24 | python/podman-mcp-server.egg-info/ 25 | !python/podman-mcp-server 26 | ``` -------------------------------------------------------------------------------- /python/README.md: -------------------------------------------------------------------------------- ```markdown 1 | ../README.md ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Podman MCP Server 2 | 3 | [](https://github.com/manusa/podman-mcp-server/blob/main/LICENSE) 4 | [](https://www.npmjs.com/package/podman-mcp-server) 5 | [](https://pypi.org/project/podman-mcp-server/) 6 | [](https://github.com/manusa/podman-mcp-server/releases/latest) 7 | [](https://github.com/manusa/podman-mcp-server/actions/workflows/build.yaml) 8 | 9 | [✨ Features](#features) | [🚀 Getting Started](#getting-started) | [🎥 Demos](#demos) | [⚙️ Configuration](#configuration) | [🧑💻 Development](#development) 10 | 11 | ## ✨ Features <a id="features"></a> 12 | 13 | A powerful and flexible MCP server for container runtimes supporting Podman and Docker. 14 | 15 | 16 | ## 🚀 Getting Started <a id="getting-started"></a> 17 | 18 | ### Claude Desktop 19 | 20 | #### Using npx 21 | 22 | If you have npm installed, this is the fastest way to get started with `podman-mcp-server` on Claude Desktop. 23 | 24 | Open your `claude_desktop_config.json` and add the mcp server to the list of `mcpServers`: 25 | ``` json 26 | { 27 | "mcpServers": { 28 | "podman": { 29 | "command": "npx", 30 | "args": [ 31 | "-y", 32 | "podman-mcp-server@latest" 33 | ] 34 | } 35 | } 36 | } 37 | ``` 38 | 39 | ### VS Code / VS Code Insiders 40 | 41 | Install the Podman MCP server extension in VS Code Insiders by pressing the following link: 42 | 43 | [<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522podman%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522podman-mcp-server%2540latest%2522%255D%257D) 44 | [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522podman%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522podman-mcp-server%2540latest%2522%255D%257D) 45 | 46 | Alternatively, you can install the extension manually by running the following command: 47 | 48 | ```shell 49 | # For VS Code 50 | code --add-mcp '{"name":"podman","command":"npx","args":["podman-mcp-server@latest"]}' 51 | # For VS Code Insiders 52 | code-insiders --add-mcp '{"name":"podman","command":"npx","args":["podman-mcp-server@latest"]}' 53 | ``` 54 | 55 | ### Goose CLI 56 | 57 | [Goose CLI](https://blog.marcnuri.com/goose-on-machine-ai-agent-cli-introduction) is the easiest (and cheapest) way to get rolling with artificial intelligence (AI) agents. 58 | 59 | #### Using npm 60 | 61 | If you have npm installed, this is the fastest way to get started with `podman-mcp-server`. 62 | 63 | Open your goose `config.yaml` and add the mcp server to the list of `mcpServers`: 64 | ```yaml 65 | extensions: 66 | podman: 67 | command: npx 68 | args: 69 | - -y 70 | - podman-mcp-server@latest 71 | 72 | ``` 73 | 74 | ## 🎥 Demos <a id="demos"></a> 75 | 76 | ## ⚙️ Configuration <a id="configuration"></a> 77 | 78 | The Podman MCP server can be configured using command line (CLI) arguments. 79 | 80 | You can run the CLI executable either by using `npx` or by downloading the [latest release binary](https://github.com/manusa/podman-mcp-server/releases/latest). 81 | 82 | ```shell 83 | # Run the Podman MCP server using npx (in case you have npm installed) 84 | npx podman-mcp-server@latest --help 85 | ``` 86 | 87 | ```shell 88 | # Run the Podman MCP server using the latest release binary 89 | ./podman-mcp-server --help 90 | ``` 91 | 92 | ### Configuration Options 93 | 94 | | Option | Description | 95 | |--------------|------------------------------------------------------------------------------------------| 96 | | `--sse-port` | Starts the MCP server in Server-Sent Event (SSE) mode and listens on the specified port. | 97 | 98 | ## 🧑💻 Development <a id="development"></a> 99 | 100 | ### Running with mcp-inspector 101 | 102 | Compile the project and run the Podman MCP server with [mcp-inspector](https://modelcontextprotocol.io/docs/tools/inspector) to inspect the MCP server. 103 | 104 | ```shell 105 | # Compile the project 106 | make build 107 | # Run the Podman MCP server with mcp-inspector 108 | npx @modelcontextprotocol/inspector@latest $(pwd)/podman-mcp-server 109 | ``` 110 | ``` -------------------------------------------------------------------------------- /python/podman_mcp_server/__main__.py: -------------------------------------------------------------------------------- ```python 1 | from .podman_mcp_server import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | ``` -------------------------------------------------------------------------------- /cmd/podman-mcp-server/main.go: -------------------------------------------------------------------------------- ```go 1 | package main 2 | 3 | import "github.com/manusa/podman-mcp-server/pkg/podman-mcp-server/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | ``` -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- ```yaml 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 10 8 | ``` -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- ```go 1 | package version 2 | 3 | var CommitHash = "unknown" 4 | var BuildTime = "1970-01-01T00:00:00Z" 5 | var Version = "0.0.0" 6 | var BinaryName = "podman-mcp-server" 7 | ``` -------------------------------------------------------------------------------- /python/podman_mcp_server/__init__.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Model Context Protocol (MCP) server for container runtimes (Podman and Docker) 3 | """ 4 | from .podman_mcp_server import main 5 | 6 | __all__ = ['main'] 7 | 8 | ``` -------------------------------------------------------------------------------- /cmd/podman-mcp-server/main_test.go: -------------------------------------------------------------------------------- ```go 1 | package main 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func Example_version() { 8 | oldArgs := os.Args 9 | defer func() { os.Args = oldArgs }() 10 | os.Args = []string{"podman-mcp-server", "--version"} 11 | main() 12 | // Output: 0.0.0 13 | } 14 | ``` -------------------------------------------------------------------------------- /npm/podman-mcp-server-linux-amd64/package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "podman-mcp-server-linux-amd64", 3 | "version": "0.0.0", 4 | "description": "Model Context Protocol (MCP) server for container runtimes (Podman and Docker) ", 5 | "os": [ 6 | "linux" 7 | ], 8 | "cpu": [ 9 | "x64" 10 | ] 11 | } 12 | ``` -------------------------------------------------------------------------------- /npm/podman-mcp-server-darwin-amd64/package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "podman-mcp-server-darwin-amd64", 3 | "version": "0.0.0", 4 | "description": "Model Context Protocol (MCP) server for container runtimes (Podman and Docker) ", 5 | "os": [ 6 | "darwin" 7 | ], 8 | "cpu": [ 9 | "x64" 10 | ] 11 | } 12 | ``` -------------------------------------------------------------------------------- /npm/podman-mcp-server-linux-arm64/package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "podman-mcp-server-linux-arm64", 3 | "version": "0.0.0", 4 | "description": "Model Context Protocol (MCP) server for container runtimes (Podman and Docker) ", 5 | "os": [ 6 | "linux" 7 | ], 8 | "cpu": [ 9 | "arm64" 10 | ] 11 | } 12 | ``` -------------------------------------------------------------------------------- /npm/podman-mcp-server-windows-amd64/package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "podman-mcp-server-windows-amd64", 3 | "version": "0.0.0", 4 | "description": "Model Context Protocol (MCP) server for container runtimes (Podman and Docker) ", 5 | "os": [ 6 | "win32" 7 | ], 8 | "cpu": [ 9 | "x64" 10 | ] 11 | } 12 | ``` -------------------------------------------------------------------------------- /npm/podman-mcp-server-darwin-arm64/package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "podman-mcp-server-darwin-arm64", 3 | "version": "0.0.0", 4 | "description": "Model Context Protocol (MCP) server for container runtimes (Podman and Docker) ", 5 | "os": [ 6 | "darwin" 7 | ], 8 | "cpu": [ 9 | "arm64" 10 | ] 11 | } 12 | ``` -------------------------------------------------------------------------------- /npm/podman-mcp-server-windows-arm64/package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "podman-mcp-server-windows-arm64", 3 | "version": "0.0.0", 4 | "description": "Model Context Protocol (MCP) server for container runtimes (Podman and Docker) ", 5 | "os": [ 6 | "win32" 7 | ], 8 | "cpu": [ 9 | "arm64" 10 | ] 11 | } 12 | ``` -------------------------------------------------------------------------------- /testdata/podman/main.go: -------------------------------------------------------------------------------- ```go 1 | // Fake podman CLI binary 2 | package main 3 | 4 | import ( 5 | "os" 6 | "path" 7 | "path/filepath" 8 | ) 9 | 10 | func main() { 11 | print("podman") 12 | for _, arg := range os.Args[1:] { 13 | print(" " + arg) 14 | } 15 | println() 16 | ex, err := os.Executable() 17 | if err != nil { 18 | panic(err) 19 | } 20 | outputTxt := path.Join(filepath.Dir(ex), "output.txt") 21 | _, err = os.Stat(outputTxt) 22 | if err == nil { 23 | data, _ := os.ReadFile(outputTxt) 24 | _, _ = os.Stdout.Write(data) 25 | } 26 | os.Exit(0) 27 | } 28 | ``` -------------------------------------------------------------------------------- /pkg/mcp/podman_volume.go: -------------------------------------------------------------------------------- ```go 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "github.com/mark3labs/mcp-go/mcp" 6 | "github.com/mark3labs/mcp-go/server" 7 | ) 8 | 9 | func (s *Server) initPodmanVolume() []server.ServerTool { 10 | return []server.ServerTool{ 11 | {mcp.NewTool("volume_list", 12 | mcp.WithDescription("List all the available Docker or Podman volumes"), 13 | ), s.volumeList}, 14 | } 15 | } 16 | 17 | func (s *Server) volumeList(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { 18 | return NewTextResult(s.podman.VolumeList()), nil 19 | } 20 | ``` -------------------------------------------------------------------------------- /pkg/mcp/podman_network.go: -------------------------------------------------------------------------------- ```go 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "github.com/mark3labs/mcp-go/mcp" 6 | "github.com/mark3labs/mcp-go/server" 7 | ) 8 | 9 | func (s *Server) initPodmanNetwork() []server.ServerTool { 10 | return []server.ServerTool{ 11 | {mcp.NewTool("network_list", 12 | mcp.WithDescription("List all the available Docker or Podman networks"), 13 | ), s.networkList}, 14 | } 15 | } 16 | 17 | func (s *Server) networkList(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { 18 | return NewTextResult(s.podman.NetworkList()), nil 19 | } 20 | ``` -------------------------------------------------------------------------------- /pkg/podman-mcp-server/cmd/root_test.go: -------------------------------------------------------------------------------- ```go 1 | package cmd 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func captureOutput(f func() error) (string, error) { 10 | originalOut := os.Stdout 11 | defer func() { 12 | os.Stdout = originalOut 13 | }() 14 | r, w, _ := os.Pipe() 15 | os.Stdout = w 16 | err := f() 17 | _ = w.Close() 18 | out, _ := io.ReadAll(r) 19 | return string(out), err 20 | } 21 | 22 | func TestVersion(t *testing.T) { 23 | rootCmd.SetArgs([]string{"--version"}) 24 | version, err := captureOutput(rootCmd.Execute) 25 | if version != "0.0.0\n" { 26 | t.Fatalf("Expected version 0.0.0, got %s %v", version, err) 27 | return 28 | } 29 | } 30 | ``` -------------------------------------------------------------------------------- /pkg/mcp/podman_volume_test.go: -------------------------------------------------------------------------------- ```go 1 | package mcp 2 | 3 | import ( 4 | "github.com/mark3labs/mcp-go/mcp" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestVolumeList(t *testing.T) { 10 | testCase(t, func(c *mcpContext) { 11 | toolResult, err := c.callTool("volume_list", map[string]interface{}{}) 12 | t.Run("volume_list returns OK", func(t *testing.T) { 13 | if err != nil { 14 | t.Fatalf("call tool failed %v", err) 15 | } 16 | if toolResult.IsError { 17 | t.Fatalf("call tool failed") 18 | } 19 | }) 20 | t.Run("volume_list lists all available volumes", func(t *testing.T) { 21 | if !strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "podman volume ls") { 22 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 23 | } 24 | }) 25 | }) 26 | } 27 | ``` -------------------------------------------------------------------------------- /pkg/mcp/podman_network_test.go: -------------------------------------------------------------------------------- ```go 1 | package mcp 2 | 3 | import ( 4 | "github.com/mark3labs/mcp-go/mcp" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestNetworkList(t *testing.T) { 10 | testCase(t, func(c *mcpContext) { 11 | toolResult, err := c.callTool("network_list", map[string]interface{}{}) 12 | t.Run("network_list returns OK", func(t *testing.T) { 13 | if err != nil { 14 | t.Fatalf("call tool failed %v", err) 15 | } 16 | if toolResult.IsError { 17 | t.Fatalf("call tool failed") 18 | } 19 | }) 20 | t.Run("network_list lists all available networks", func(t *testing.T) { 21 | if !strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "podman network ls") { 22 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 23 | } 24 | }) 25 | }) 26 | } 27 | ``` -------------------------------------------------------------------------------- /python/pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "podman-mcp-server" 7 | version = "0.0.0" 8 | description = "Model Context Protocol (MCP) server for container runtimes (Podman and Docker)" 9 | readme = {file="README.md", content-type="text/markdown"} 10 | requires-python = ">=3.6" 11 | license = "Apache-2.0" 12 | authors = [ 13 | { name = "Marc Nuri", email = "[email protected]" } 14 | ] 15 | classifiers = [ 16 | "Programming Language :: Python :: 3", 17 | "Operating System :: OS Independent", 18 | ] 19 | 20 | [project.urls] 21 | Homepage = "https://github.com/manusa/podman-mcp-server" 22 | Repository = "https://github.com/manusa/podman-mcp-server" 23 | 24 | [project.scripts] 25 | podman-mcp-server = "podman_mcp_server:main" 26 | ``` -------------------------------------------------------------------------------- /pkg/mcp/mcp_test.go: -------------------------------------------------------------------------------- ```go 1 | package mcp 2 | 3 | import ( 4 | "github.com/mark3labs/mcp-go/mcp" 5 | "testing" 6 | ) 7 | 8 | func TestTools(t *testing.T) { 9 | expectedNames := []string{ 10 | "container_inspect", 11 | "container_list", 12 | "container_logs", 13 | "container_remove", 14 | "container_run", 15 | "container_stop", 16 | "image_build", 17 | "image_list", 18 | "image_pull", 19 | "image_push", 20 | "image_remove", 21 | "network_list", 22 | "volume_list", 23 | } 24 | testCase(t, func(c *mcpContext) { 25 | tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{}) 26 | t.Run("ListTools returns tools", func(t *testing.T) { 27 | if err != nil { 28 | t.Fatalf("call ListTools failed %v", err) 29 | } 30 | }) 31 | nameSet := make(map[string]bool) 32 | for _, tool := range tools.Tools { 33 | nameSet[tool.Name] = true 34 | } 35 | for _, name := range expectedNames { 36 | t.Run("ListTools has "+name+" tool", func(t *testing.T) { 37 | if nameSet[name] != true { 38 | t.Errorf("tool %s not found", name) 39 | } 40 | }) 41 | } 42 | }) 43 | } 44 | ``` -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- ```yaml 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | paths-ignore: 8 | - '.gitignore' 9 | - 'LICENSE' 10 | - '*.md' 11 | pull_request: 12 | paths-ignore: 13 | - '.gitignore' 14 | - 'LICENSE' 15 | - '*.md' 16 | 17 | concurrency: 18 | # Only run once for latest commit per ref and cancel other (previous) runs. 19 | group: ${{ github.workflow }}-${{ github.ref }} 20 | cancel-in-progress: true 21 | 22 | env: 23 | GO_VERSION: 1.23 24 | 25 | defaults: 26 | run: 27 | shell: bash 28 | 29 | jobs: 30 | build: 31 | name: Build on ${{ matrix.os }} 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | os: 36 | - ubuntu-latest #x64 37 | - ubuntu-24.04-arm #arm64 38 | - windows-latest #x64 39 | - macos-13 #x64 40 | - macos-latest #arm64 41 | runs-on: ${{ matrix.os }} 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v4 45 | - uses: actions/setup-go@v5 46 | with: 47 | go-version: ${{ env.GO_VERSION }} 48 | - name: Build 49 | run: make build 50 | - name: Test 51 | run: make test 52 | ``` -------------------------------------------------------------------------------- /npm/podman-mcp-server/bin/index.js: -------------------------------------------------------------------------------- ```javascript 1 | #!/usr/bin/env node 2 | 3 | const childProcess = require('child_process'); 4 | 5 | const BINARY_MAP = { 6 | darwin_x64: {name: 'podman-mcp-server-darwin-amd64', suffix: ''}, 7 | darwin_arm64: {name: 'podman-mcp-server-darwin-arm64', suffix: ''}, 8 | linux_x64: {name: 'podman-mcp-server-linux-amd64', suffix: ''}, 9 | linux_arm64: {name: 'podman-mcp-server-linux-arm64', suffix: ''}, 10 | win32_x64: {name: 'podman-mcp-server-windows-amd64', suffix: '.exe'}, 11 | win32_arm64: {name: 'podman-mcp-server-windows-arm64', suffix: '.exe'}, 12 | }; 13 | 14 | // Resolving will fail if the optionalDependency was not installed or the platform/arch is not supported 15 | const resolveBinaryPath = () => { 16 | try { 17 | const binary = BINARY_MAP[`${process.platform}_${process.arch}`]; 18 | return require.resolve(`${binary.name}/bin/${binary.name}${binary.suffix}`); 19 | } catch (e) { 20 | throw new Error(`Could not resolve binary path for platform/arch: ${process.platform}/${process.arch}`); 21 | } 22 | }; 23 | 24 | childProcess.execFileSync(resolveBinaryPath(), process.argv.slice(2), { 25 | stdio: 'inherit', 26 | }); 27 | 28 | ``` -------------------------------------------------------------------------------- /npm/podman-mcp-server/package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "podman-mcp-server", 3 | "version": "0.0.0", 4 | "description": "Model Context Protocol (MCP) server for container runtimes (Podman and Docker) ", 5 | "main": "./bin/index.js", 6 | "bin": { 7 | "podman-mcp-server": "bin/index.js" 8 | }, 9 | "optionalDependencies": { 10 | "podman-mcp-server-darwin-amd64": "0.0.0", 11 | "podman-mcp-server-darwin-arm64": "0.0.0", 12 | "podman-mcp-server-linux-amd64": "0.0.0", 13 | "podman-mcp-server-linux-arm64": "0.0.0", 14 | "podman-mcp-server-windows-amd64": "0.0.0", 15 | "podman-mcp-server-windows-arm64": "0.0.0" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/manusa/podman-mcp-server.git" 20 | }, 21 | "keywords": [ 22 | "mcp", 23 | "podman", 24 | "docker", 25 | "containers", 26 | "container-runtime", 27 | "model context protocol", 28 | "model", 29 | "context", 30 | "protocol" 31 | ], 32 | "author": { 33 | "name": "Marc Nuri", 34 | "url": "https://www.marcnuri.com" 35 | }, 36 | "license": "Apache-2.0", 37 | "bugs": { 38 | "url": "https://github.com/manusa/podman-mcp-server/issues" 39 | }, 40 | "homepage": "https://github.com/manusa/podman-mcp-server#readme" 41 | } 42 | ``` -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- ```yaml 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | concurrency: 9 | # Only run once for latest commit per ref and cancel other (previous) runs. 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | GO_VERSION: 1.23 15 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 16 | UV_PUBLISH_TOKEN: ${{ secrets.UV_PUBLISH_TOKEN }} 17 | 18 | permissions: 19 | contents: write 20 | discussions: write 21 | 22 | jobs: 23 | release: 24 | name: Release 25 | runs-on: macos-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | - uses: actions/setup-go@v5 30 | with: 31 | go-version: ${{ env.GO_VERSION }} 32 | - name: Build 33 | run: make build-all-platforms 34 | - name: Upload artifacts 35 | uses: softprops/action-gh-release@v2 36 | with: 37 | generate_release_notes: true 38 | make_latest: true 39 | files: | 40 | LICENSE 41 | podman-mcp-server-* 42 | - name: Publish npm 43 | run: 44 | make npm-publish 45 | python: 46 | name: Release Python 47 | # Python logic requires the tag/release version to be available from GitHub 48 | needs: release 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: Checkout 52 | uses: actions/checkout@v4 53 | - uses: astral-sh/setup-uv@v5 54 | - name: Publish Python 55 | run: 56 | make python-publish 57 | ``` -------------------------------------------------------------------------------- /pkg/mcp/mcp.go: -------------------------------------------------------------------------------- ```go 1 | package mcp 2 | 3 | import ( 4 | "github.com/manusa/podman-mcp-server/pkg/podman" 5 | "github.com/manusa/podman-mcp-server/pkg/version" 6 | "github.com/mark3labs/mcp-go/mcp" 7 | "github.com/mark3labs/mcp-go/server" 8 | "slices" 9 | ) 10 | 11 | type Server struct { 12 | server *server.MCPServer 13 | podman podman.Podman 14 | } 15 | 16 | func NewSever() (*Server, error) { 17 | s := &Server{ 18 | server: server.NewMCPServer( 19 | version.BinaryName, 20 | version.Version, 21 | server.WithResourceCapabilities(true, true), 22 | server.WithPromptCapabilities(true), 23 | server.WithToolCapabilities(true), 24 | server.WithLogging(), 25 | ), 26 | } 27 | var err error 28 | if s.podman, err = podman.NewPodman(); err != nil { 29 | return nil, err 30 | } 31 | s.server.AddTools(slices.Concat( 32 | s.initPodmanContainer(), 33 | s.initPodmanImage(), 34 | s.initPodmanNetwork(), 35 | s.initPodmanVolume(), 36 | )...) 37 | return s, nil 38 | } 39 | 40 | func (s *Server) ServeStdio() error { 41 | return server.ServeStdio(s.server) 42 | } 43 | 44 | func (s *Server) ServeSse(baseUrl string) *server.SSEServer { 45 | options := make([]server.SSEOption, 0) 46 | if baseUrl != "" { 47 | options = append(options, server.WithBaseURL(baseUrl)) 48 | } 49 | return server.NewSSEServer(s.server, options...) 50 | } 51 | 52 | func NewTextResult(content string, err error) *mcp.CallToolResult { 53 | if err != nil { 54 | return &mcp.CallToolResult{ 55 | IsError: true, 56 | Content: []mcp.Content{ 57 | mcp.TextContent{ 58 | Type: "text", 59 | Text: err.Error(), 60 | }, 61 | }, 62 | } 63 | } 64 | return &mcp.CallToolResult{ 65 | Content: []mcp.Content{ 66 | mcp.TextContent{ 67 | Type: "text", 68 | Text: content, 69 | }, 70 | }, 71 | } 72 | } 73 | ``` -------------------------------------------------------------------------------- /pkg/podman/interface.go: -------------------------------------------------------------------------------- ```go 1 | package podman 2 | 3 | // Podman interface 4 | type Podman interface { 5 | // ContainerInspect displays the low-level information on containers identified by the ID or name 6 | ContainerInspect(name string) (string, error) 7 | // ContainerList lists all the containers on the system 8 | ContainerList() (string, error) 9 | // ContainerLogs Display the logs of a container 10 | ContainerLogs(name string) (string, error) 11 | // ContainerRemove removes a container 12 | ContainerRemove(name string) (string, error) 13 | // ContainerRun pulls an image from a registry 14 | ContainerRun(imageName string, portMappings map[int]int, envVariables []string) (string, error) 15 | // ContainerStop stops a running container using the ID or name 16 | ContainerStop(name string) (string, error) 17 | // ImageBuild builds an image from a Dockerfile, Podmanfile, or Containerfile 18 | ImageBuild(containerFile string, imageName string) (string, error) 19 | // ImageList list the container images on the system 20 | ImageList() (string, error) 21 | // ImagePull pulls an image from a registry 22 | ImagePull(imageName string) (string, error) 23 | // ImagePush pushes an image to a registry 24 | ImagePush(imageName string) (string, error) 25 | // ImageRemove removes an image from the system 26 | ImageRemove(imageName string) (string, error) 27 | // NetworkList lists all the networks on the system 28 | NetworkList() (string, error) 29 | // VolumeList lists all the volumes on the system 30 | VolumeList() (string, error) 31 | } 32 | 33 | func NewPodman() (Podman, error) { 34 | // TODO: add implementations for Podman bindings and Docker CLI 35 | return newPodmanCli() 36 | } 37 | ``` -------------------------------------------------------------------------------- /pkg/podman-mcp-server/cmd/root.go: -------------------------------------------------------------------------------- ```go 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/manusa/podman-mcp-server/pkg/mcp" 7 | "github.com/manusa/podman-mcp-server/pkg/version" 8 | "github.com/mark3labs/mcp-go/server" 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/viper" 11 | "golang.org/x/net/context" 12 | ) 13 | 14 | var rootCmd = &cobra.Command{ 15 | Use: "podman-mcp-server [command] [options]", 16 | Short: "Podman Model Context Protocol (MCP) server", 17 | Long: ` 18 | Podman Model Context Protocol (MCP) server 19 | 20 | # show this help 21 | podman-mcp-server -h 22 | 23 | # shows version information 24 | podman-mcp-server --version 25 | 26 | # start STDIO server 27 | podman-mcp-server 28 | 29 | # start a SSE server on port 8080 30 | podman-mcp-server --sse-port 8080 31 | 32 | # start a SSE server on port 8443 with a public HTTPS host of example.com 33 | podman-mcp-server --sse-port 8443 --sse-base-url https://example.com:8443 34 | 35 | # TODO: add more examples`, 36 | Run: func(cmd *cobra.Command, args []string) { 37 | if viper.GetBool("version") { 38 | fmt.Println(version.Version) 39 | return 40 | } 41 | mcpServer, err := mcp.NewSever() 42 | if err != nil { 43 | panic(err) 44 | } 45 | 46 | var sseServer *server.SSEServer 47 | if ssePort := viper.GetInt("sse-port"); ssePort > 0 { 48 | sseServer = mcpServer.ServeSse(viper.GetString("sse-base-url")) 49 | if err := sseServer.Start(fmt.Sprintf(":%d", ssePort)); err != nil { 50 | panic(err) 51 | } 52 | } 53 | if err := mcpServer.ServeStdio(); err != nil && !errors.Is(err, context.Canceled) { 54 | panic(err) 55 | } 56 | if sseServer != nil { 57 | _ = sseServer.Shutdown(cmd.Context()) 58 | } 59 | }, 60 | } 61 | 62 | func init() { 63 | rootCmd.Flags().BoolP("version", "v", false, "Print version information and quit") 64 | rootCmd.Flags().IntP("sse-port", "", 0, "Start a SSE server on the specified port") 65 | rootCmd.Flags().StringP("sse-base-url", "", "", "SSE public base URL to use when sending the endpoint message (e.g. https://example.com)") 66 | _ = viper.BindPFlags(rootCmd.Flags()) 67 | } 68 | 69 | func Execute() { 70 | if err := rootCmd.Execute(); err != nil { 71 | panic(err) 72 | } 73 | } 74 | ``` -------------------------------------------------------------------------------- /pkg/mcp/common_test.go: -------------------------------------------------------------------------------- ```go 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/mark3labs/mcp-go/client" 7 | "github.com/mark3labs/mcp-go/mcp" 8 | "github.com/mark3labs/mcp-go/server" 9 | "net/http/httptest" 10 | "os" 11 | "os/exec" 12 | "path" 13 | "runtime" 14 | "testing" 15 | ) 16 | 17 | type mcpContext struct { 18 | podmanBinaryDir string 19 | ctx context.Context 20 | cancel context.CancelFunc 21 | mcpServer *Server 22 | mcpHttpServer *httptest.Server 23 | mcpClient *client.Client 24 | } 25 | 26 | func (c *mcpContext) beforeEach(t *testing.T) { 27 | var err error 28 | c.ctx, c.cancel = context.WithCancel(context.Background()) 29 | if c.mcpServer, err = NewSever(); err != nil { 30 | t.Fatal(err) 31 | return 32 | } 33 | c.mcpHttpServer = server.NewTestServer(c.mcpServer.server) 34 | if c.mcpClient, err = client.NewSSEMCPClient(c.mcpHttpServer.URL + "/sse"); err != nil { 35 | t.Fatal(err) 36 | return 37 | } 38 | if err = c.mcpClient.Start(c.ctx); err != nil { 39 | t.Fatal(err) 40 | return 41 | } 42 | initRequest := mcp.InitializeRequest{} 43 | initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION 44 | initRequest.Params.ClientInfo = mcp.Implementation{Name: "test", Version: "1.33.7"} 45 | _, err = c.mcpClient.Initialize(c.ctx, initRequest) 46 | if err != nil { 47 | t.Fatal(err) 48 | return 49 | } 50 | } 51 | 52 | func (c *mcpContext) afterEach() { 53 | c.cancel() 54 | _ = c.mcpClient.Close() 55 | c.mcpHttpServer.Close() 56 | } 57 | 58 | func testCase(t *testing.T, test func(c *mcpContext)) { 59 | mcpCtx := &mcpContext{ 60 | podmanBinaryDir: withPodmanBinary(t), 61 | } 62 | mcpCtx.beforeEach(t) 63 | defer mcpCtx.afterEach() 64 | test(mcpCtx) 65 | } 66 | 67 | // callTool helper function to call a tool by name with arguments 68 | func (c *mcpContext) callTool(name string, args map[string]interface{}) (*mcp.CallToolResult, error) { 69 | callToolRequest := mcp.CallToolRequest{} 70 | callToolRequest.Params.Name = name 71 | callToolRequest.Params.Arguments = args 72 | return c.mcpClient.CallTool(c.ctx, callToolRequest) 73 | } 74 | 75 | func (c *mcpContext) withPodmanOutput(outputLines ...string) { 76 | if len(outputLines) > 0 { 77 | f, _ := os.Create(path.Join(c.podmanBinaryDir, "output.txt")) 78 | defer f.Close() 79 | for _, line := range outputLines { 80 | _, _ = f.WriteString(line + "\n") 81 | } 82 | } 83 | } 84 | 85 | func withPodmanBinary(t *testing.T) string { 86 | binDir := t.TempDir() 87 | binary := "podman" 88 | if runtime.GOOS == "windows" { 89 | binary += ".exe" 90 | } 91 | output, err := exec. 92 | Command("go", "build", "-o", path.Join(binDir, binary), 93 | path.Join("..", "..", "testdata", "podman", "main.go")). 94 | CombinedOutput() 95 | if err != nil { 96 | panic(fmt.Errorf("failed to generate podman binary: %w, output: %s", err, string(output))) 97 | } 98 | if os.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) != nil { 99 | panic("failed to set PATH") 100 | } 101 | return binDir 102 | } 103 | ``` -------------------------------------------------------------------------------- /pkg/mcp/podman_image.go: -------------------------------------------------------------------------------- ```go 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "github.com/mark3labs/mcp-go/mcp" 6 | "github.com/mark3labs/mcp-go/server" 7 | ) 8 | 9 | func (s *Server) initPodmanImage() []server.ServerTool { 10 | return []server.ServerTool{ 11 | {mcp.NewTool("image_build", 12 | mcp.WithDescription("Build a Docker or Podman image from a Dockerfile, Podmanfile, or Containerfile"), 13 | mcp.WithString("containerFile", mcp.Description("The absolute path to the Dockerfile, Podmanfile, or Containerfile to build the image from"), mcp.Required()), 14 | mcp.WithString("imageName", mcp.Description("Specifies the name which is assigned to the resulting image if the build process completes successfully (--tag, -t)")), 15 | ), s.imageBuild}, 16 | {mcp.NewTool("image_list", 17 | mcp.WithDescription("List the Docker or Podman images on the local machine"), 18 | ), s.imageList}, 19 | {mcp.NewTool("image_pull", 20 | mcp.WithDescription("Copies (pulls) a Docker or Podman container image from a registry onto the local machine storage"), 21 | mcp.WithString("imageName", mcp.Description("Docker or Podman container image name to pull"), mcp.Required()), 22 | ), s.imagePull}, 23 | {mcp.NewTool("image_push", 24 | mcp.WithDescription("Pushes a Docker or Podman container image, manifest list or image index from local machine storage to a registry"), 25 | mcp.WithString("imageName", mcp.Description("Docker or Podman container image name to push"), mcp.Required()), 26 | ), s.imagePush}, 27 | {mcp.NewTool("image_remove", 28 | mcp.WithDescription("Removes a Docker or Podman image from the local machine storage"), 29 | mcp.WithString("imageName", mcp.Description("Docker or Podman container image name to remove"), mcp.Required()), 30 | ), s.imageRemove}, 31 | } 32 | } 33 | 34 | func (s *Server) imageBuild(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 35 | imageName := ctr.GetArguments()["imageName"] 36 | if _, ok := imageName.(string); !ok { 37 | imageName = "" 38 | } 39 | return NewTextResult(s.podman.ImageBuild(ctr.GetArguments()["containerFile"].(string), imageName.(string))), nil 40 | } 41 | 42 | func (s *Server) imageList(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { 43 | return NewTextResult(s.podman.ImageList()), nil 44 | } 45 | 46 | func (s *Server) imagePull(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 47 | return NewTextResult(s.podman.ImagePull(ctr.GetArguments()["imageName"].(string))), nil 48 | } 49 | 50 | func (s *Server) imagePush(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 51 | return NewTextResult(s.podman.ImagePush(ctr.GetArguments()["imageName"].(string))), nil 52 | } 53 | 54 | func (s *Server) imageRemove(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 55 | return NewTextResult(s.podman.ImageRemove(ctr.GetArguments()["imageName"].(string))), nil 56 | } 57 | ``` -------------------------------------------------------------------------------- /python/podman_mcp_server/podman_mcp_server.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | import platform 3 | import subprocess 4 | import sys 5 | from pathlib import Path 6 | import shutil 7 | import tempfile 8 | import urllib.request 9 | 10 | if sys.version_info >= (3, 8): 11 | from importlib.metadata import version 12 | else: 13 | from importlib_metadata import version 14 | 15 | __version__ = version("podman-mcp-server") 16 | 17 | def get_platform_binary(): 18 | """Determine the correct binary for the current platform.""" 19 | system = platform.system().lower() 20 | arch = platform.machine().lower() 21 | 22 | # Normalize architecture names 23 | if arch in ["x86_64", "amd64"]: 24 | arch = "amd64" 25 | elif arch in ["arm64", "aarch64"]: 26 | arch = "arm64" 27 | else: 28 | raise RuntimeError(f"Unsupported architecture: {arch}") 29 | 30 | if system == "darwin": 31 | return f"podman-mcp-server-darwin-{arch}" 32 | elif system == "linux": 33 | return f"podman-mcp-server-linux-{arch}" 34 | elif system == "windows": 35 | return f"podman-mcp-server-windows-{arch}.exe" 36 | else: 37 | raise RuntimeError(f"Unsupported operating system: {system}") 38 | 39 | def download_binary(binary_version="latest", destination=None): 40 | """Download the correct binary for the current platform.""" 41 | binary_name = get_platform_binary() 42 | if destination is None: 43 | destination = Path.home() / ".podman-mcp-server" / "bin" / binary_version 44 | 45 | destination = Path(destination) 46 | destination.mkdir(parents=True, exist_ok=True) 47 | binary_path = destination / binary_name 48 | 49 | if binary_path.exists(): 50 | return binary_path 51 | 52 | base_url = "https://github.com/manusa/podman-mcp-server/releases" 53 | if binary_version == "latest": 54 | release_url = f"{base_url}/latest/download/{binary_name}" 55 | else: 56 | release_url = f"{base_url}/download/v{binary_version}/{binary_name}" 57 | 58 | # Download the binary 59 | print(f"Downloading {binary_name} from {release_url}") 60 | with tempfile.NamedTemporaryFile(delete=False) as temp_file: 61 | try: 62 | with urllib.request.urlopen(release_url) as response: 63 | shutil.copyfileobj(response, temp_file) 64 | temp_file.close() 65 | 66 | # Move to destination and make executable 67 | shutil.move(temp_file.name, binary_path) 68 | binary_path.chmod(binary_path.stat().st_mode | 0o755) # Make executable 69 | 70 | return binary_path 71 | except Exception as e: 72 | os.unlink(temp_file.name) 73 | raise RuntimeError(f"Failed to download binary: {e}") 74 | 75 | def execute(args=None): 76 | """Download and execute the podman-mcp-server binary.""" 77 | if args is None: 78 | args = [] 79 | 80 | try: 81 | binary_path = download_binary(binary_version=__version__) 82 | cmd = [str(binary_path)] + args 83 | 84 | # Execute the binary with the provided arguments 85 | process = subprocess.run(cmd) 86 | return process.returncode 87 | except Exception as e: 88 | print(f"Error executing podman-mcp-server: {e}", file=sys.stderr) 89 | return 1 90 | 91 | if __name__ == "__main__": 92 | sys.exit(execute(sys.argv[1:])) 93 | 94 | 95 | def main(): 96 | """Main function to execute the podman-mcp-server binary.""" 97 | args = sys.argv[1:] if len(sys.argv) > 1 else [] 98 | return execute(args) 99 | ``` -------------------------------------------------------------------------------- /pkg/mcp/podman_image_test.go: -------------------------------------------------------------------------------- ```go 1 | package mcp 2 | 3 | import ( 4 | "github.com/mark3labs/mcp-go/mcp" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestImageBuild(t *testing.T) { 10 | testCase(t, func(c *mcpContext) { 11 | toolResult, err := c.callTool("image_build", map[string]interface{}{ 12 | "containerFile": "/tmp/Containerfile", 13 | }) 14 | t.Run("image_build returns OK", func(t *testing.T) { 15 | if err != nil { 16 | t.Fatalf("call tool failed %v", err) 17 | } 18 | if toolResult.IsError { 19 | t.Fatalf("call tool failed") 20 | } 21 | if !strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "podman build -f /tmp/Containerfile") { 22 | t.Errorf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 23 | } 24 | }) 25 | toolResult, err = c.callTool("image_build", map[string]interface{}{ 26 | "containerFile": "/tmp/Containerfile", 27 | "imageName": "example.com/org/image:tag", 28 | }) 29 | t.Run("image_build with imageName returns OK", func(t *testing.T) { 30 | if err != nil { 31 | t.Fatalf("call tool failed %v", err) 32 | } 33 | if toolResult.IsError { 34 | t.Fatalf("call tool failed") 35 | } 36 | if !strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "podman build -t example.com/org/image:tag -f /tmp/Containerfile") { 37 | t.Errorf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 38 | } 39 | }) 40 | }) 41 | } 42 | 43 | func TestImageList(t *testing.T) { 44 | testCase(t, func(c *mcpContext) { 45 | c.withPodmanOutput( 46 | "REPOSITORY\tTAG\tDIGEST\tIMAGE ID\tCREATED\tSIZE", 47 | "docker.io/marcnuri/chuck-norris\nlatest\nsha256:1337\nb8f22a2b8410\n1 day ago\n37 MB", 48 | ) 49 | toolResult, err := c.callTool("image_list", map[string]interface{}{}) 50 | t.Run("image_list returns OK", func(t *testing.T) { 51 | if err != nil { 52 | t.Fatalf("call tool failed %v", err) 53 | return 54 | } 55 | if toolResult.IsError { 56 | t.Fatalf("call tool failed") 57 | return 58 | } 59 | if !strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "podman images --digests") { 60 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 61 | return 62 | } 63 | }) 64 | }) 65 | } 66 | 67 | func TestImagePull(t *testing.T) { 68 | testCase(t, func(c *mcpContext) { 69 | toolResult, err := c.callTool("image_pull", map[string]interface{}{ 70 | "imageName": "example.com/org/image:tag", 71 | }) 72 | t.Run("image_pull returns OK", func(t *testing.T) { 73 | if err != nil { 74 | t.Fatalf("call tool failed %v", err) 75 | } 76 | if toolResult.IsError { 77 | t.Fatalf("call tool failed") 78 | } 79 | if !strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "podman image pull example.com/org/image:tag") { 80 | t.Errorf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 81 | } 82 | if !strings.HasSuffix(toolResult.Content[0].(mcp.TextContent).Text, "example.com/org/image:tag pulled successfully") { 83 | t.Errorf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 84 | } 85 | }) 86 | }) 87 | } 88 | 89 | func TestImagePush(t *testing.T) { 90 | testCase(t, func(c *mcpContext) { 91 | toolResult, err := c.callTool("image_push", map[string]interface{}{ 92 | "imageName": "example.com/org/image:tag", 93 | }) 94 | t.Run("image_push returns OK", func(t *testing.T) { 95 | if err != nil { 96 | t.Fatalf("call tool failed %v", err) 97 | } 98 | if toolResult.IsError { 99 | t.Fatalf("call tool failed") 100 | } 101 | if !strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "podman image push example.com/org/image:tag") { 102 | t.Errorf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 103 | } 104 | if !strings.HasSuffix(toolResult.Content[0].(mcp.TextContent).Text, "example.com/org/image:tag pushed successfully") { 105 | t.Errorf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 106 | } 107 | }) 108 | }) 109 | } 110 | 111 | func TestImageRemove(t *testing.T) { 112 | testCase(t, func(c *mcpContext) { 113 | toolResult, err := c.callTool("image_remove", map[string]interface{}{ 114 | "imageName": "example.com/org/image:tag", 115 | }) 116 | t.Run("image_remove returns OK", func(t *testing.T) { 117 | if err != nil { 118 | t.Fatalf("call tool failed %v", err) 119 | } 120 | if toolResult.IsError { 121 | t.Fatalf("call tool failed") 122 | } 123 | if !strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "podman image rm example.com/org/image:tag") { 124 | t.Errorf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 125 | } 126 | }) 127 | }) 128 | } 129 | ``` -------------------------------------------------------------------------------- /pkg/podman/podman_cli.go: -------------------------------------------------------------------------------- ```go 1 | package podman 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os/exec" 7 | "strings" 8 | ) 9 | 10 | type podmanCli struct { 11 | filePath string 12 | } 13 | 14 | // ContainerInspect 15 | // https://docs.podman.io/en/stable/markdown/podman-inspect.1.html 16 | func (p *podmanCli) ContainerInspect(name string) (string, error) { 17 | return p.exec("inspect", name) 18 | } 19 | 20 | // ContainerList 21 | // https://docs.podman.io/en/stable/markdown/podman-ps.1.html 22 | func (p *podmanCli) ContainerList() (string, error) { 23 | return p.exec("container", "list", "-a") 24 | } 25 | 26 | // ContainerLogs 27 | // https://docs.podman.io/en/stable/markdown/podman-logs.1.html 28 | func (p *podmanCli) ContainerLogs(name string) (string, error) { 29 | return p.exec("logs", name) 30 | } 31 | 32 | // ContainerRemove 33 | // https://docs.podman.io/en/stable/markdown/podman-rm.1.html 34 | func (p *podmanCli) ContainerRemove(name string) (string, error) { 35 | return p.exec("container", "rm", name) 36 | } 37 | 38 | // ContainerRun 39 | // https://docs.podman.io/en/stable/markdown/podman-run.1.html 40 | func (p *podmanCli) ContainerRun(imageName string, portMappings map[int]int, envVariables []string) (string, error) { 41 | args := []string{"run", "--rm", "-d"} 42 | if len(portMappings) > 0 { 43 | for hostPort, containerPort := range portMappings { 44 | args = append(args, fmt.Sprintf("--publish=%d:%d", hostPort, containerPort)) 45 | } 46 | } else { 47 | args = append(args, "--publish-all") 48 | } 49 | for _, env := range envVariables { 50 | args = append(args, "--env", env) 51 | } 52 | output, err := p.exec(append(args, imageName)...) 53 | if err == nil { 54 | return output, nil 55 | } 56 | if strings.Contains(output, "Error: short-name") { 57 | imageName = "docker.io/" + imageName 58 | if output, err = p.exec(append(args, imageName)...); err == nil { 59 | return output, nil 60 | } 61 | } 62 | return "", err 63 | } 64 | 65 | // ContainerStop 66 | // https://docs.podman.io/en/stable/markdown/podman-stop.1.html 67 | func (p *podmanCli) ContainerStop(name string) (string, error) { 68 | return p.exec("container", "stop", name) 69 | } 70 | 71 | // ImageBuild 72 | // https://docs.podman.io/en/stable/markdown/podman-build.1.html 73 | func (p *podmanCli) ImageBuild(containerFile string, imageName string) (string, error) { 74 | args := []string{"build"} 75 | if imageName != "" { 76 | args = append(args, "-t", imageName) 77 | } 78 | return p.exec(append(args, "-f", containerFile)...) 79 | } 80 | 81 | // ImageList 82 | // https://docs.podman.io/en/stable/markdown/podman-images.1.html 83 | func (p *podmanCli) ImageList() (string, error) { 84 | return p.exec("images", "--digests") 85 | } 86 | 87 | // ImagePull 88 | // https://docs.podman.io/en/stable/markdown/podman-pull.1.html 89 | func (p *podmanCli) ImagePull(imageName string) (string, error) { 90 | output, err := p.exec("image", "pull", imageName) 91 | if err == nil { 92 | return fmt.Sprintf("%s\n%s pulled successfully", output, imageName), nil 93 | } 94 | if strings.Contains(output, "Error: short-name") { 95 | imageName = "docker.io/" + imageName 96 | if output, err = p.exec("pull", imageName); err == nil { 97 | return fmt.Sprintf("%s\n%s pulled successfully", output, imageName), nil 98 | } 99 | } 100 | return "", err 101 | } 102 | 103 | // ImagePush 104 | // https://docs.podman.io/en/stable/markdown/podman-push.1.html 105 | func (p *podmanCli) ImagePush(imageName string) (string, error) { 106 | output, err := p.exec("image", "push", imageName) 107 | if err == nil { 108 | return fmt.Sprintf("%s\n%s pushed successfully", output, imageName), nil 109 | } 110 | return "", err 111 | } 112 | 113 | // ImageRemove 114 | // https://docs.podman.io/en/stable/markdown/podman-rmi.1.html 115 | func (p *podmanCli) ImageRemove(imageName string) (string, error) { 116 | return p.exec("image", "rm", imageName) 117 | } 118 | 119 | // NetworkList 120 | // https://docs.podman.io/en/stable/markdown/podman-network-ls.1.html 121 | func (p *podmanCli) NetworkList() (string, error) { 122 | return p.exec("network", "ls") 123 | } 124 | 125 | // VolumeList 126 | // https://docs.podman.io/en/stable/markdown/podman-volume-ls.1.html 127 | func (p *podmanCli) VolumeList() (string, error) { 128 | return p.exec("volume", "ls") 129 | } 130 | 131 | func (p *podmanCli) exec(args ...string) (string, error) { 132 | output, err := exec.Command(p.filePath, args...).CombinedOutput() 133 | return string(output), err 134 | } 135 | 136 | func newPodmanCli() (*podmanCli, error) { 137 | for _, cmd := range []string{"podman", "podman.exe"} { 138 | filePath, err := exec.LookPath(cmd) 139 | if err != nil { 140 | continue 141 | } 142 | if _, err = exec.Command(filePath, "version").CombinedOutput(); err == nil { 143 | return &podmanCli{filePath}, nil 144 | } 145 | } 146 | return nil, errors.New("podman CLI not found") 147 | } 148 | ``` -------------------------------------------------------------------------------- /pkg/mcp/podman_container.go: -------------------------------------------------------------------------------- ```go 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "github.com/mark3labs/mcp-go/mcp" 6 | "github.com/mark3labs/mcp-go/server" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | func (s *Server) initPodmanContainer() []server.ServerTool { 12 | return []server.ServerTool{ 13 | {mcp.NewTool("container_inspect", 14 | mcp.WithDescription("Displays the low-level information and configuration of a Docker or Podman container with the specified container ID or name"), 15 | mcp.WithString("name", mcp.Description("Docker or Podman container ID or name to displays the information"), mcp.Required()), 16 | ), s.containerInspect}, 17 | {mcp.NewTool("container_list", 18 | mcp.WithDescription("Prints out information about the running Docker or Podman containers"), 19 | ), s.containerList}, 20 | {mcp.NewTool("container_logs", 21 | mcp.WithDescription("Displays the logs of a Docker or Podman container with the specified container ID or name"), 22 | mcp.WithString("name", mcp.Description("Docker or Podman container ID or name to displays the logs"), mcp.Required()), 23 | ), s.containerLogs}, 24 | {mcp.NewTool("container_remove", 25 | mcp.WithDescription("Removes a Docker or Podman container with the specified container ID or name (rm)"), 26 | mcp.WithString("name", mcp.Description("Docker or Podman container ID or name to remove"), mcp.Required()), 27 | ), s.containerRemove}, 28 | {mcp.NewTool("container_run", 29 | mcp.WithDescription("Runs a Docker or Podman container with the specified image name"), 30 | mcp.WithString("imageName", mcp.Description("Docker or Podman container image name to pull"), mcp.Required()), 31 | mcp.WithArray("ports", mcp.Description("Port mappings to expose on the host. "+ 32 | "Format: <hostPort>:<containerPort>. "+ 33 | "Example: 8080:80. "+ 34 | "(Optional, add only to expose ports)"), 35 | // TODO: manual fix to ensure that the items property gets initialized (Gemini) 36 | // https://www.googlecloudcommunity.com/gc/AI-ML/Gemini-API-400-Bad-Request-Array-fields-breaks-function-calling/m-p/769835?nobounce 37 | func(schema map[string]interface{}) { 38 | schema["type"] = "array" 39 | schema["items"] = map[string]interface{}{ 40 | "type": "string", 41 | } 42 | }, 43 | ), 44 | mcp.WithArray("environment", mcp.Description("Environment variables to set in the container. "+ 45 | "Format: <key>=<value>. "+ 46 | "Example: FOO=bar. "+ 47 | "(Optional, add only to set environment variables)"), 48 | // TODO: manual fix to ensure that the items property gets initialized (Gemini) 49 | // https://www.googlecloudcommunity.com/gc/AI-ML/Gemini-API-400-Bad-Request-Array-fields-breaks-function-calling/m-p/769835?nobounce 50 | func(schema map[string]interface{}) { 51 | schema["type"] = "array" 52 | schema["items"] = map[string]interface{}{ 53 | "type": "string", 54 | } 55 | }, 56 | ), 57 | ), s.containerRun}, 58 | {mcp.NewTool("container_stop", 59 | mcp.WithDescription("Stops a Docker or Podman running container with the specified container ID or name"), 60 | mcp.WithString("name", mcp.Description("Docker or Podman container ID or name to stop"), mcp.Required()), 61 | ), s.containerStop}, 62 | } 63 | } 64 | 65 | func (s *Server) containerInspect(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 66 | return NewTextResult(s.podman.ContainerInspect(ctr.GetArguments()["name"].(string))), nil 67 | } 68 | 69 | func (s *Server) containerList(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { 70 | return NewTextResult(s.podman.ContainerList()), nil 71 | } 72 | 73 | func (s *Server) containerLogs(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 74 | return NewTextResult(s.podman.ContainerLogs(ctr.GetArguments()["name"].(string))), nil 75 | } 76 | 77 | func (s *Server) containerRemove(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 78 | return NewTextResult(s.podman.ContainerRemove(ctr.GetArguments()["name"].(string))), nil 79 | } 80 | 81 | func (s *Server) containerRun(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 82 | ports := ctr.GetArguments()["ports"] 83 | portMappings := make(map[int]int) 84 | if _, ok := ports.([]interface{}); ok { 85 | for _, port := range ports.([]interface{}) { 86 | if _, ok := port.(string); !ok { 87 | continue 88 | } 89 | hostPort, _ := strconv.Atoi(strings.Split(port.(string), ":")[0]) 90 | containerPort, _ := strconv.Atoi(strings.Split(port.(string), ":")[1]) 91 | if hostPort > 0 && containerPort > 0 { 92 | portMappings[hostPort] = containerPort 93 | } 94 | } 95 | } 96 | environment := ctr.GetArguments()["environment"] 97 | envVariables := make([]string, 0) 98 | if _, ok := environment.([]interface{}); ok && len(environment.([]interface{})) > 0 { 99 | for _, env := range environment.([]interface{}) { 100 | if _, ok = env.(string); !ok { 101 | continue 102 | } 103 | envVariables = append(envVariables, env.(string)) 104 | } 105 | } 106 | return NewTextResult(s.podman.ContainerRun(ctr.GetArguments()["imageName"].(string), portMappings, envVariables)), nil 107 | } 108 | 109 | func (s *Server) containerStop(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 110 | return NewTextResult(s.podman.ContainerStop(ctr.GetArguments()["name"].(string))), nil 111 | } 112 | ``` -------------------------------------------------------------------------------- /pkg/mcp/podman_container_test.go: -------------------------------------------------------------------------------- ```go 1 | package mcp 2 | 3 | import ( 4 | "github.com/mark3labs/mcp-go/mcp" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestContainerInspect(t *testing.T) { 10 | testCase(t, func(c *mcpContext) { 11 | toolResult, err := c.callTool("container_inspect", map[string]interface{}{ 12 | "name": "example-container", 13 | }) 14 | t.Run("container_inspect returns OK", func(t *testing.T) { 15 | if err != nil { 16 | t.Fatalf("call tool failed %v", err) 17 | } 18 | if toolResult.IsError { 19 | t.Fatalf("call tool failed") 20 | } 21 | }) 22 | t.Run("container_inspect inspects provided container", func(t *testing.T) { 23 | if !strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "podman inspect example-container") { 24 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 25 | } 26 | }) 27 | }) 28 | } 29 | 30 | func TestContainerList(t *testing.T) { 31 | testCase(t, func(c *mcpContext) { 32 | toolResult, err := c.callTool("container_list", map[string]interface{}{}) 33 | t.Run("container_list returns OK", func(t *testing.T) { 34 | if err != nil { 35 | t.Fatalf("call tool failed %v", err) 36 | } 37 | if toolResult.IsError { 38 | t.Fatalf("call tool failed") 39 | } 40 | if !strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "podman container list -a") { 41 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 42 | } 43 | }) 44 | }) 45 | } 46 | 47 | func TestContainerLogs(t *testing.T) { 48 | testCase(t, func(c *mcpContext) { 49 | toolResult, err := c.callTool("container_logs", map[string]interface{}{ 50 | "name": "example-container", 51 | }) 52 | t.Run("container_logs returns OK", func(t *testing.T) { 53 | if err != nil { 54 | t.Fatalf("call tool failed %v", err) 55 | } 56 | if toolResult.IsError { 57 | t.Fatalf("call tool failed") 58 | } 59 | }) 60 | t.Run("container_logs retrieves logs from provided container", func(t *testing.T) { 61 | if !strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "podman logs example-container") { 62 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 63 | } 64 | }) 65 | }) 66 | } 67 | 68 | func TestContainerRemove(t *testing.T) { 69 | testCase(t, func(c *mcpContext) { 70 | toolResult, err := c.callTool("container_remove", map[string]interface{}{ 71 | "name": "example-container", 72 | }) 73 | t.Run("container_remove returns OK", func(t *testing.T) { 74 | if err != nil { 75 | t.Fatalf("call tool failed %v", err) 76 | } 77 | if toolResult.IsError { 78 | t.Fatalf("call tool failed") 79 | } 80 | }) 81 | t.Run("container_remove removes provided container", func(t *testing.T) { 82 | if !strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "podman container rm example-container") { 83 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 84 | } 85 | }) 86 | }) 87 | } 88 | 89 | func TestContainerRun(t *testing.T) { 90 | testCase(t, func(c *mcpContext) { 91 | toolResult, err := c.callTool("container_run", map[string]interface{}{ 92 | "imageName": "example.com/org/image:tag", 93 | }) 94 | t.Run("container_run returns OK", func(t *testing.T) { 95 | if err != nil { 96 | t.Fatalf("call tool failed %v", err) 97 | } 98 | if toolResult.IsError { 99 | t.Fatalf("call tool failed") 100 | } 101 | }) 102 | t.Run("container_run runs provided image", func(t *testing.T) { 103 | if !strings.HasSuffix(toolResult.Content[0].(mcp.TextContent).Text, " example.com/org/image:tag\n") { 104 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 105 | } 106 | }) 107 | t.Run("container_run runs in detached mode", func(t *testing.T) { 108 | if !strings.Contains(toolResult.Content[0].(mcp.TextContent).Text, " -d ") { 109 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 110 | } 111 | }) 112 | t.Run("container_run publishes all exposed ports", func(t *testing.T) { 113 | if !strings.Contains(toolResult.Content[0].(mcp.TextContent).Text, " --publish-all ") { 114 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 115 | } 116 | }) 117 | toolResult, err = c.callTool("container_run", map[string]interface{}{ 118 | "imageName": "example.com/org/image:tag", 119 | "ports": []interface{}{ 120 | 1337, // Invalid entry to test 121 | "8080:80", 122 | "8082:8082", 123 | "8443:443", 124 | }, 125 | }) 126 | t.Run("container_run with ports returns OK", func(t *testing.T) { 127 | if err != nil { 128 | t.Fatalf("call tool failed %v", err) 129 | } 130 | if toolResult.IsError { 131 | t.Fatalf("call tool failed") 132 | } 133 | }) 134 | t.Run("container_run with ports publishes provided ports", func(t *testing.T) { 135 | if !strings.Contains(toolResult.Content[0].(mcp.TextContent).Text, " --publish=8080:80 ") { 136 | t.Fatalf("expected port --publish=8080:80, got %v", toolResult.Content[0].(mcp.TextContent).Text) 137 | } 138 | if !strings.Contains(toolResult.Content[0].(mcp.TextContent).Text, " --publish=8082:8082 ") { 139 | t.Fatalf("expected port --publish=8082:8082, got %v", toolResult.Content[0].(mcp.TextContent).Text) 140 | } 141 | if !strings.Contains(toolResult.Content[0].(mcp.TextContent).Text, " --publish=8443:443 ") { 142 | t.Fatalf("expected port --publish=8443:443, got %v", toolResult.Content[0].(mcp.TextContent).Text) 143 | } 144 | }) 145 | toolResult, err = c.callTool("container_run", map[string]interface{}{ 146 | "imageName": "example.com/org/image:tag", 147 | "ports": []interface{}{"8080:80"}, 148 | "environment": []interface{}{ 149 | "KEY=VALUE", 150 | "FOO=BAR", 151 | }, 152 | }) 153 | t.Run("container_run with environment returns OK", func(t *testing.T) { 154 | if err != nil { 155 | t.Fatalf("call tool failed %v", err) 156 | } 157 | if toolResult.IsError { 158 | t.Fatalf("call tool failed") 159 | } 160 | }) 161 | t.Run("container_run with environment sets provided environment variables", func(t *testing.T) { 162 | if !strings.Contains(toolResult.Content[0].(mcp.TextContent).Text, " --env KEY=VALUE ") { 163 | t.Fatalf("expected env --env KEY=VALUE, got %v", toolResult.Content[0].(mcp.TextContent).Text) 164 | } 165 | if !strings.Contains(toolResult.Content[0].(mcp.TextContent).Text, " --env FOO=BAR ") { 166 | t.Fatalf("expected env --env FOO=BAR, got %v", toolResult.Content[0].(mcp.TextContent).Text) 167 | } 168 | }) 169 | }) 170 | } 171 | 172 | func TestContainerStop(t *testing.T) { 173 | testCase(t, func(c *mcpContext) { 174 | toolResult, err := c.callTool("container_stop", map[string]interface{}{ 175 | "name": "example-container", 176 | }) 177 | t.Run("container_stop returns OK", func(t *testing.T) { 178 | if err != nil { 179 | t.Fatalf("call tool failed %v", err) 180 | } 181 | if toolResult.IsError { 182 | t.Fatalf("call tool failed") 183 | } 184 | }) 185 | t.Run("container_stop stops provided container", func(t *testing.T) { 186 | if !strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "podman container stop example-container") { 187 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 188 | } 189 | }) 190 | }) 191 | } 192 | ```