# 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 |
```