multipass-mcp 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - v*
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ pypi:
11
+ name: Publish to PyPI
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ contents: read
15
+ packages: write
16
+ attestations: write
17
+ id-token: write
18
+ environment:
19
+ name: release
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: astral-sh/setup-uv@v3
23
+ - run: uv build
24
+ - run: uv publish --trusted-publishing always
@@ -0,0 +1,216 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+
204
+ # Ruff stuff:
205
+ .ruff_cache/
206
+
207
+ # PyPI configuration file
208
+ .pypirc
209
+
210
+ # Marimo
211
+ marimo/_static/
212
+ marimo/_lsp/
213
+ __marimo__/
214
+
215
+ # Streamlit
216
+ .streamlit/secrets.toml
@@ -0,0 +1,57 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v5.0.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: check-yaml
8
+ - id: debug-statements
9
+ - id: double-quote-string-fixer
10
+ - id: name-tests-test
11
+ - id: requirements-txt-fixer
12
+ - id: check-json
13
+ - id: pretty-format-json
14
+ args: [--autofix, --indent=4]
15
+ - repo: https://github.com/asottile/setup-cfg-fmt
16
+ rev: v2.8.0
17
+ hooks:
18
+ - id: setup-cfg-fmt
19
+ - repo: https://github.com/asottile/reorder-python-imports
20
+ rev: v3.15.0
21
+ hooks:
22
+ - id: reorder-python-imports
23
+ exclude: ^(pre_commit/resources/|testing/resources/python3_hooks_repo/)
24
+ args: [--py312-plus]
25
+ - repo: https://github.com/asottile/add-trailing-comma
26
+ rev: v3.2.0
27
+ hooks:
28
+ - id: add-trailing-comma
29
+ - repo: https://github.com/asottile/pyupgrade
30
+ rev: v3.20.0
31
+ hooks:
32
+ - id: pyupgrade
33
+ args: [--py312-plus]
34
+ - repo: https://github.com/hhatto/autopep8
35
+ rev: v2.3.2
36
+ hooks:
37
+ - id: autopep8
38
+ - repo: https://github.com/PyCQA/flake8
39
+ rev: 7.3.0
40
+ hooks:
41
+ - id: flake8
42
+ args: ['--ignore=E501,W504']
43
+ - repo: https://github.com/PyCQA/autoflake
44
+ rev: v2.3.1
45
+ hooks:
46
+ - id: autoflake
47
+ args: [--remove-all-unused-imports, --in-place]
48
+ - repo: https://github.com/pre-commit/mirrors-mypy
49
+ rev: v1.17.1
50
+ hooks:
51
+ - id: mypy
52
+ additional_dependencies:
53
+ - types-pyyaml
54
+ - types-requests
55
+ - pandas-stubs
56
+ - sqlmodel
57
+ exclude: ^(testing/resources/|benchmarks/)
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: multipass-mcp
3
+ Version: 0.1.1
4
+ Summary: Multipass MCP Server
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: fastmcp>=3.1.0
7
+ Description-Content-Type: text/markdown
8
+
9
+ # Multipass MCP Server
10
+
11
+ A Model Context Protocol (MCP) server to manage [Multipass](https://multipass.run/) instances.
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ claude mcp add --transport stdio multipass -- uvx multipass-mcp
17
+ ```
18
+
19
+ ## Available Tools
20
+
21
+ - `list_instances`: List all instances.
22
+ - `launch_instance`: Create a new instance.
23
+ - `start_instance` / `stop_instance` / `delete_instance`: Manage state.
24
+ - `execute_command`: Run commands inside an instance.
25
+ - `get_instance_info`: Get detailed specs (CPU, Memory, Disk).
26
+ - `purge_instances`: Cleanup deleted instances.
@@ -0,0 +1,18 @@
1
+ # Multipass MCP Server
2
+
3
+ A Model Context Protocol (MCP) server to manage [Multipass](https://multipass.run/) instances.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ claude mcp add --transport stdio multipass -- uvx multipass-mcp
9
+ ```
10
+
11
+ ## Available Tools
12
+
13
+ - `list_instances`: List all instances.
14
+ - `launch_instance`: Create a new instance.
15
+ - `start_instance` / `stop_instance` / `delete_instance`: Manage state.
16
+ - `execute_command`: Run commands inside an instance.
17
+ - `get_instance_info`: Get detailed specs (CPU, Memory, Disk).
18
+ - `purge_instances`: Cleanup deleted instances.
@@ -0,0 +1,3 @@
1
+ from .server import mcp
2
+
3
+ __all__ = ['mcp']
@@ -0,0 +1,113 @@
1
+ import asyncio
2
+ import json
3
+ from dataclasses import dataclass
4
+ from dataclasses import field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class MultipassInstance:
10
+ name: str
11
+ state: str
12
+ ipv4: list[str]
13
+ release: str
14
+
15
+
16
+ @dataclass
17
+ class MultipassInfo:
18
+ name: str
19
+ state: str
20
+ ipv4: list[str]
21
+ release: str
22
+ image_hash: str = ''
23
+ load: list[float] = field(default_factory=list)
24
+ disk_usage: dict[str, str] = field(default_factory=dict)
25
+ memory_usage: dict[str, str] = field(default_factory=dict)
26
+ mounts: dict[str, Any] = field(default_factory=dict)
27
+
28
+
29
+ class MultipassCLI:
30
+ async def _run(self, *args: str) -> str:
31
+ process = await asyncio.create_subprocess_exec(
32
+ 'multipass',
33
+ *args,
34
+ stdout=asyncio.subprocess.PIPE,
35
+ stderr=asyncio.subprocess.PIPE,
36
+ )
37
+ stdout, stderr = await process.communicate()
38
+ if process.returncode != 0:
39
+ raise Exception(f"Multipass error: {stderr.decode().strip()}")
40
+ return stdout.decode().strip()
41
+
42
+ async def list_instances(self) -> list[MultipassInstance]:
43
+ output = await self._run('list', '--format', 'json')
44
+ data = json.loads(output)
45
+ instances = []
46
+ for item in data.get('list', []):
47
+ instances.append(
48
+ MultipassInstance(
49
+ name=item['name'],
50
+ state=item['state'],
51
+ ipv4=item['ipv4'],
52
+ release=item['release'],
53
+ ),
54
+ )
55
+ return instances
56
+
57
+ async def start_instance(self, name: str) -> str:
58
+ return await self._run('start', name)
59
+
60
+ async def stop_instance(self, name: str) -> str:
61
+ return await self._run('stop', name)
62
+
63
+ async def delete_instance(self, name: str, purge: bool = False) -> str:
64
+ args = ['delete', name]
65
+ if purge:
66
+ args.append('--purge')
67
+ return await self._run(*args)
68
+
69
+ async def purge_instances(self) -> str:
70
+ return await self._run('purge')
71
+
72
+ async def execute_command(self, name: str, command: str) -> str:
73
+ return await self._run('exec', name, '--', *command.split())
74
+
75
+ async def get_info(self, name: str) -> MultipassInfo:
76
+ output = await self._run('info', name, '--format', 'json')
77
+ data = json.loads(output)
78
+ info = data.get('info', {}).get(name, {})
79
+ if not info:
80
+ raise Exception(f"Instance {name} not found")
81
+
82
+ return MultipassInfo(
83
+ name=name,
84
+ state=info.get('state', ''),
85
+ ipv4=info.get('ipv4', []),
86
+ release=info.get('release', ''),
87
+ image_hash=info.get('image_hash', ''),
88
+ load=info.get('load', []),
89
+ disk_usage=info.get('disks', {}),
90
+ memory_usage=info.get('memory', {}),
91
+ mounts=info.get('mounts', {}),
92
+ )
93
+
94
+ async def launch_instance(
95
+ self,
96
+ name: str | None = None,
97
+ image: str | None = None,
98
+ cpus: int | None = None,
99
+ memory: str | None = None,
100
+ disk: str | None = None,
101
+ ) -> str:
102
+ args = ['launch']
103
+ if name:
104
+ args.extend(['--name', name])
105
+ if image:
106
+ args.append(image)
107
+ if cpus:
108
+ args.extend(['--cpus', str(cpus)])
109
+ if memory:
110
+ args.extend(['--memory', memory])
111
+ if disk:
112
+ args.extend(['--disk', disk])
113
+ return await self._run(*args)
@@ -0,0 +1,66 @@
1
+ from fastmcp import FastMCP
2
+
3
+ from .multipass import MultipassCLI
4
+ from .multipass import MultipassInfo
5
+ from .multipass import MultipassInstance
6
+
7
+ # Initialize MCP server
8
+ mcp = FastMCP('Multipass')
9
+ cli = MultipassCLI()
10
+
11
+
12
+ @mcp.tool()
13
+ async def list_instances() -> list[MultipassInstance]:
14
+ """List all Multipass instances."""
15
+ return await cli.list_instances()
16
+
17
+
18
+ @mcp.tool()
19
+ async def launch_instance(
20
+ name: str | None = None,
21
+ image: str | None = None,
22
+ cpus: int | None = None,
23
+ memory: str | None = None,
24
+ disk: str | None = None,
25
+ ) -> str:
26
+ """Launch a new Multipass instance."""
27
+ return await cli.launch_instance(name, image, cpus, memory, disk)
28
+
29
+
30
+ @mcp.tool()
31
+ async def start_instance(name: str) -> str:
32
+ """Start a Multipass instance."""
33
+ return await cli.start_instance(name)
34
+
35
+
36
+ @mcp.tool()
37
+ async def stop_instance(name: str) -> str:
38
+ """Stop a Multipass instance."""
39
+ return await cli.stop_instance(name)
40
+
41
+
42
+ @mcp.tool()
43
+ async def delete_instance(name: str, purge: bool = False) -> str:
44
+ """Delete a Multipass instance."""
45
+ return await cli.delete_instance(name, purge)
46
+
47
+
48
+ @mcp.tool()
49
+ async def purge_instances() -> str:
50
+ """Purge all deleted Multipass instances."""
51
+ return await cli.purge_instances()
52
+
53
+
54
+ @mcp.tool()
55
+ async def execute_command(name: str, command: str) -> str:
56
+ """Execute a command in a Multipass instance."""
57
+ return await cli.execute_command(name, command)
58
+
59
+
60
+ @mcp.tool()
61
+ async def get_instance_info(name: str) -> MultipassInfo:
62
+ """Get detailed information about a Multipass instance."""
63
+ return await cli.get_info(name)
64
+
65
+ if __name__ == '__main__':
66
+ mcp.run()
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "multipass-mcp"
3
+ version = "0.1.1"
4
+ description = "Multipass MCP Server"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "fastmcp>=3.1.0",
9
+ ]
10
+
11
+ [dependency-groups]
12
+ dev = [
13
+ "pytest>=9.0.2",
14
+ "pytest-asyncio>=1.3.0",
15
+ ]
16
+
17
+ [build-system]
18
+ requires = ["hatchling"]
19
+ build-backend = "hatchling.build"
20
+
21
+ [project.scripts]
22
+ multipass-mcp = "multipass_mcp.server:mcp.run"
23
+
24
+ [tool.bumpversion]
25
+ parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
26
+ serialize = ["{major}.{minor}.{patch}"]
27
+ search = "{current_version}"
28
+ replace = "{new_version}"
29
+ regex = false
30
+ ignore_missing_version = false
31
+ ignore_missing_files = false
32
+ tag = false
33
+ sign_tags = false
34
+ tag_name = "v{new_version}"
35
+ tag_message = "Bump version: {current_version} → {new_version}"
36
+ allow_dirty = false
37
+ commit = false
38
+ message = "Bump version: {current_version} → {new_version}"
39
+ moveable_tags = []
40
+ commit_args = ""
41
+ setup_hooks = []
42
+ pre_commit_hooks = []
43
+ post_commit_hooks = []
44
+
45
+ [tool.bump-my-version.files]
46
+ "pyproject.toml" = [
47
+ 'project.version'
48
+ ]
File without changes
@@ -0,0 +1,94 @@
1
+ import json
2
+ from unittest.mock import AsyncMock
3
+ from unittest.mock import patch
4
+
5
+ import pytest
6
+
7
+ from multipass_mcp.multipass import MultipassCLI
8
+ from multipass_mcp.multipass import MultipassInfo
9
+ from multipass_mcp.multipass import MultipassInstance
10
+
11
+
12
+ @pytest.fixture
13
+ def cli():
14
+ return MultipassCLI()
15
+
16
+
17
+ @pytest.mark.asyncio
18
+ async def test_list_instances(cli):
19
+ mock_output = json.dumps({
20
+ 'list': [
21
+ {
22
+ 'name': 'test-vm', 'state': 'Running',
23
+ 'ipv4': ['192.168.64.2'], 'release': 'Ubuntu 22.04 LTS',
24
+ },
25
+ ],
26
+ })
27
+
28
+ with patch('asyncio.create_subprocess_exec') as mock_exec:
29
+ mock_process = AsyncMock()
30
+ mock_process.communicate.return_value = (mock_output.encode(), b'')
31
+ mock_process.returncode = 0
32
+ mock_exec.return_value = mock_process
33
+
34
+ result = await cli.list_instances()
35
+ assert len(result) == 1
36
+ assert isinstance(result[0], MultipassInstance)
37
+ assert result[0].name == 'test-vm'
38
+ mock_exec.assert_called_with(
39
+ 'multipass', 'list', '--format', 'json',
40
+ stdout=-1, stderr=-1,
41
+ )
42
+
43
+
44
+ @pytest.mark.asyncio
45
+ async def test_get_info(cli):
46
+ mock_output = json.dumps({
47
+ 'info': {
48
+ 'test-vm': {
49
+ 'state': 'Running',
50
+ 'ipv4': ['192.168.64.2'],
51
+ 'release': 'Ubuntu 22.04 LTS',
52
+ 'image_hash': 'hash123',
53
+ },
54
+ },
55
+ })
56
+
57
+ with patch('asyncio.create_subprocess_exec') as mock_exec:
58
+ mock_process = AsyncMock()
59
+ mock_process.communicate.return_value = (mock_output.encode(), b'')
60
+ mock_process.returncode = 0
61
+ mock_exec.return_value = mock_process
62
+
63
+ result = await cli.get_info('test-vm')
64
+ assert isinstance(result, MultipassInfo)
65
+ assert result.name == 'test-vm'
66
+ assert result.image_hash == 'hash123'
67
+
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_start_instance(cli):
71
+ with patch('asyncio.create_subprocess_exec') as mock_exec:
72
+ mock_process = AsyncMock()
73
+ mock_process.communicate.return_value = (b'', b'')
74
+ mock_process.returncode = 0
75
+ mock_exec.return_value = mock_process
76
+
77
+ await cli.start_instance('test-vm')
78
+ mock_exec.assert_called_with(
79
+ 'multipass', 'start', 'test-vm',
80
+ stdout=-1, stderr=-1,
81
+ )
82
+
83
+
84
+ @pytest.mark.asyncio
85
+ async def test_error_handling(cli):
86
+ with patch('asyncio.create_subprocess_exec') as mock_exec:
87
+ mock_process = AsyncMock()
88
+ mock_process.communicate.return_value = (b'', b'Error message')
89
+ mock_process.returncode = 1
90
+ mock_exec.return_value = mock_process
91
+
92
+ with pytest.raises(Exception) as excinfo:
93
+ await cli.list_instances()
94
+ assert 'Multipass error: Error message' in str(excinfo.value)