contree-mcp 0.1.0__py3-none-any.whl

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.
Files changed (46) hide show
  1. contree_mcp/__init__.py +0 -0
  2. contree_mcp/__main__.py +25 -0
  3. contree_mcp/app.py +240 -0
  4. contree_mcp/arguments.py +35 -0
  5. contree_mcp/auth/__init__.py +2 -0
  6. contree_mcp/auth/registry.py +236 -0
  7. contree_mcp/backend_types.py +301 -0
  8. contree_mcp/cache.py +208 -0
  9. contree_mcp/client.py +711 -0
  10. contree_mcp/context.py +53 -0
  11. contree_mcp/docs.py +1203 -0
  12. contree_mcp/file_cache.py +381 -0
  13. contree_mcp/prompts.py +238 -0
  14. contree_mcp/py.typed +0 -0
  15. contree_mcp/resources/__init__.py +17 -0
  16. contree_mcp/resources/guide.py +715 -0
  17. contree_mcp/resources/image_lineage.py +46 -0
  18. contree_mcp/resources/image_ls.py +32 -0
  19. contree_mcp/resources/import_operation.py +52 -0
  20. contree_mcp/resources/instance_operation.py +52 -0
  21. contree_mcp/resources/read_file.py +33 -0
  22. contree_mcp/resources/static.py +12 -0
  23. contree_mcp/server.py +77 -0
  24. contree_mcp/tools/__init__.py +39 -0
  25. contree_mcp/tools/cancel_operation.py +36 -0
  26. contree_mcp/tools/download.py +128 -0
  27. contree_mcp/tools/get_guide.py +54 -0
  28. contree_mcp/tools/get_image.py +30 -0
  29. contree_mcp/tools/get_operation.py +26 -0
  30. contree_mcp/tools/import_image.py +99 -0
  31. contree_mcp/tools/list_files.py +80 -0
  32. contree_mcp/tools/list_images.py +50 -0
  33. contree_mcp/tools/list_operations.py +46 -0
  34. contree_mcp/tools/read_file.py +47 -0
  35. contree_mcp/tools/registry_auth.py +71 -0
  36. contree_mcp/tools/registry_token_obtain.py +80 -0
  37. contree_mcp/tools/rsync.py +46 -0
  38. contree_mcp/tools/run.py +97 -0
  39. contree_mcp/tools/set_tag.py +31 -0
  40. contree_mcp/tools/upload.py +50 -0
  41. contree_mcp/tools/wait_operations.py +79 -0
  42. contree_mcp-0.1.0.dist-info/METADATA +450 -0
  43. contree_mcp-0.1.0.dist-info/RECORD +46 -0
  44. contree_mcp-0.1.0.dist-info/WHEEL +4 -0
  45. contree_mcp-0.1.0.dist-info/entry_points.txt +2 -0
  46. contree_mcp-0.1.0.dist-info/licenses/LICENSE +176 -0
File without changes
@@ -0,0 +1,25 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import sys
5
+
6
+ from contree_mcp.arguments import Parser
7
+ from contree_mcp.server import amain
8
+
9
+
10
+ def main() -> None:
11
+ parser = Parser(
12
+ config_files=[os.getenv("CONTREE_MCP_CONFIG", "~/.config/contree/mcp.ini")],
13
+ auto_env_var_prefix="CONTREE_MCP_",
14
+ )
15
+ parser.parse_args()
16
+
17
+ logging.basicConfig(level=parser.log_level, format="[%(levelname)s] %(message)s", stream=sys.stderr)
18
+ try:
19
+ asyncio.run(amain(parser))
20
+ except KeyboardInterrupt:
21
+ logging.info("Gracefully exited on keyboard interrupt")
22
+
23
+
24
+ if __name__ == "__main__":
25
+ main()
contree_mcp/app.py ADDED
@@ -0,0 +1,240 @@
1
+ """
2
+ ## MANDATORY WORKFLOW
3
+
4
+ Every task MUST follow this sequence:
5
+
6
+ ### Step 1: CHECK for Prepared Environment
7
+ ```
8
+ list_images(tag_prefix="common/")
9
+ ```
10
+ Search for existing prepared images before creating new ones.
11
+
12
+ ### Step 2: PREPARE Environment (if not found)
13
+ ```
14
+ import_image(registry_url="docker://python:3.11-slim")
15
+ run(command="pip install ...", image="<uuid>", disposable=false)
16
+ set_tag(image_uuid="<result>", tag="common/python-ml/python:3.11-slim")
17
+ ```
18
+ Build and TAG prepared images for reuse. CRITICAL: Use `disposable=false` to save state.
19
+
20
+ ### Step 3: EXECUTE Task
21
+ ```
22
+ run(command="...", image="tag:common/python-ml/python:3.11-slim")
23
+ ```
24
+ Use tagged image for efficient execution.
25
+
26
+ ---
27
+
28
+ ## Tagging Convention
29
+
30
+ ```
31
+ {scope}/{purpose}/{base}:{tag}
32
+ ```
33
+
34
+ | Component | Description | Examples |
35
+ |-----------|-------------|----------|
36
+ | `{scope}` | `common` or project name | `common`, `myproject` |
37
+ | `{purpose}` | What was added/configured | `rust-toolchain`, `python-ml`, `web-deps` |
38
+ | `{base}:{tag}` | Original base image | `ubuntu:noble`, `python:3.11-slim` |
39
+
40
+ **Examples:**
41
+ - `common/rust-toolchain/ubuntu:noble` - Ubuntu with Rust
42
+ - `common/python-ml/python:3.11-slim` - Python with ML libraries
43
+ - `myproject/dev-env/python:3.11-slim` - Project-specific setup
44
+
45
+ ---
46
+
47
+ ## NEVER DO THESE
48
+
49
+ | Anti-pattern | Consequence | Correct approach |
50
+ |--------------|-------------|------------------|
51
+ | Import without checking | Wastes 10s-30min on duplicate imports | `list_images(tag_prefix="...")` first |
52
+ | Skip tagging prepared images | Rebuilds from scratch next time | `set_tag()` after installing deps |
53
+ | Chain commands in one run | Cannot rollback individual steps | One step per `run` |
54
+ | Use `disposable=true` for installs | Loses all installed packages | `disposable=false` for setup |
55
+
56
+ ---
57
+
58
+ ## Tool Cost Reference
59
+
60
+ | Tool | Cost | Notes |
61
+ |------|------|-------|
62
+ | `run` | VM (~2-5s) | Command execution |
63
+ | `import_image` | VM (~10-30s) | Image pull from registry |
64
+ | `rsync`, `upload`, `download` | Free | File transfer operations |
65
+ | `list_images`, `get_image`, `set_tag` | Free | Image metadata |
66
+ | `list_files`, `read_file` | Free | Inspect container filesystem |
67
+ | `get_operation`, `list_operations`, `wait_operations`, `cancel_operation` | Free | Async management |
68
+ | `get_guide` | Free | Access documentation |
69
+
70
+ **Cost Optimization:** Use `list_files`/`read_file` instead of `run("ls")`/`run("cat")`.
71
+
72
+ ---
73
+
74
+ ## Image Inspection (Free)
75
+
76
+ Inspect container filesystem without VM cost:
77
+
78
+ ```
79
+ list_files(image="<uuid>", path="/etc") # List directory contents
80
+ read_file(image="<uuid>", path="/etc/passwd") # Read file contents
81
+ ```
82
+
83
+ Prefer these over `run("ls ...")`/`run("cat ...")` for simple inspection.
84
+
85
+ ---
86
+
87
+ ## Guides
88
+
89
+ Access documentation via resource URI or tool:
90
+
91
+ | Guide | Resource URI | Tool Alternative |
92
+ |-------|--------------|------------------|
93
+ | Workflow patterns | `contree://guide/workflow` | `get_guide(section="workflow")` |
94
+ | Async execution | `contree://guide/async` | `get_guide(section="async")` |
95
+ | Tagging convention | `contree://guide/tagging` | `get_guide(section="tagging")` |
96
+ | Tool reference | `contree://guide/reference` | `get_guide(section="reference")` |
97
+ | Error handling | `contree://guide/errors` | `get_guide(section="errors")` |
98
+
99
+ Use resources if supported by your agent runtime, otherwise use `get_guide()`.
100
+ """
101
+
102
+ import re
103
+ from collections.abc import Awaitable, Callable
104
+ from textwrap import dedent
105
+ from typing import Any
106
+
107
+ from mcp.server import FastMCP
108
+ from mcp.server.fastmcp.prompts import Prompt
109
+ from mcp.server.fastmcp.resources import ResourceTemplate
110
+ from pydantic import AnyUrl
111
+
112
+ from contree_mcp import prompts
113
+
114
+ from . import resources, tools
115
+
116
+
117
+ class PathResourceTemplate(ResourceTemplate):
118
+ """Resource template that supports path parameters with slashes.
119
+
120
+ FastMCP's default ResourceTemplate uses [^/]+ for parameters, which doesn't
121
+ match paths containing slashes. This subclass overrides the matches() method
122
+ to use .+ for the last parameter named 'path', allowing paths like 'etc/passwd'.
123
+ """
124
+
125
+ def matches(self, uri: str) -> dict[str, Any] | None:
126
+ """Check if URI matches template and extract parameters.
127
+
128
+ Uses .+ for the last {path} parameter to capture paths with slashes.
129
+ """
130
+ # Build regex pattern, using .+ for the last {path} parameter
131
+ pattern = self.uri_template
132
+
133
+ # Find all parameter names
134
+ param_names = re.findall(r"\{(\w+)\}", pattern)
135
+
136
+ for i, param in enumerate(param_names):
137
+ is_last = i == len(param_names) - 1
138
+ is_path = param == "path"
139
+
140
+ if is_last and is_path:
141
+ # Last path parameter: match anything including slashes
142
+ pattern = pattern.replace(f"{{{param}}}", f"(?P<{param}>.+)")
143
+ else:
144
+ # Regular parameter: don't match slashes
145
+ pattern = pattern.replace(f"{{{param}}}", f"(?P<{param}>[^/]+)")
146
+
147
+ match = re.match(f"^{pattern}$", uri)
148
+ if match:
149
+ return match.groupdict()
150
+ return None
151
+
152
+
153
+ def register_resource_template(mcp: FastMCP, url: str, resource_template_func: Callable[..., Awaitable[Any]]) -> None:
154
+ """
155
+ Register a resource template with the MCP app.
156
+
157
+ Uses PathResourceTemplate for URLs containing {path} to support
158
+ paths with slashes (e.g., etc/passwd) without URL encoding.
159
+ """
160
+ description = dedent(resource_template_func.__doc__ or "") or ""
161
+
162
+ # Use PathResourceTemplate for URLs with {path} parameter
163
+ if "{path}" in url:
164
+ template = PathResourceTemplate.from_function(
165
+ resource_template_func,
166
+ uri_template=url,
167
+ description=description,
168
+ )
169
+ # Directly add to resource manager's templates dict
170
+ mcp._resource_manager._templates[url] = template
171
+ else:
172
+ # Use standard FastMCP registration
173
+ decorator = mcp.resource(url, description=description)
174
+ decorator(resource_template_func)
175
+
176
+
177
+ def register_tool(mcp: FastMCP, tool_func: Callable[..., Awaitable[Any]], **kwargs: Any) -> None:
178
+ mcp.add_tool(tool_func, description=dedent(tool_func.__doc__ or "") or "", **kwargs)
179
+
180
+
181
+ def create_mcp_app(**kwargs: Any) -> FastMCP:
182
+ mcp = FastMCP(
183
+ name="contree-mcp",
184
+ instructions=dedent(__doc__).strip(),
185
+ streamable_http_path="/mcp",
186
+ json_response=True,
187
+ **kwargs,
188
+ )
189
+
190
+ register_tool(mcp, tools.list_images)
191
+ register_tool(mcp, tools.registry_token_obtain)
192
+ register_tool(mcp, tools.registry_auth)
193
+ register_tool(mcp, tools.import_image)
194
+ register_tool(mcp, tools.get_image)
195
+ register_tool(mcp, tools.set_tag)
196
+ register_tool(mcp, tools.run)
197
+ register_tool(mcp, tools.rsync)
198
+ register_tool(mcp, tools.upload)
199
+ register_tool(mcp, tools.download)
200
+ register_tool(mcp, tools.get_operation)
201
+ register_tool(mcp, tools.list_operations)
202
+ register_tool(mcp, tools.wait_operations)
203
+ register_tool(mcp, tools.cancel_operation)
204
+
205
+ # some agents can not use resources, so we expose these as tools too
206
+ register_tool(mcp, tools.list_files)
207
+ register_tool(mcp, tools.read_file)
208
+ register_tool(mcp, tools.get_guide)
209
+
210
+ mcp.add_prompt(Prompt.from_function(prompts.prepare_environment, name="prepare-environment"))
211
+ mcp.add_prompt(Prompt.from_function(prompts.run_python, name="run-python"))
212
+ mcp.add_prompt(Prompt.from_function(prompts.run_shell, name="run-shell"))
213
+ mcp.add_prompt(Prompt.from_function(prompts.sync_and_run, name="sync-and-run"))
214
+ mcp.add_prompt(Prompt.from_function(prompts.install_packages, name="install-packages"))
215
+ mcp.add_prompt(Prompt.from_function(prompts.parallel_tasks, name="parallel-tasks"))
216
+ mcp.add_prompt(Prompt.from_function(prompts.build_project, name="build-project"))
217
+ mcp.add_prompt(Prompt.from_function(prompts.debug_failure, name="debug-failure"))
218
+ mcp.add_prompt(Prompt.from_function(prompts.inspect_image, name="inspect-image"))
219
+ mcp.add_prompt(Prompt.from_function(prompts.multi_stage_build, name="multi-stage-build"))
220
+
221
+ register_resource_template(mcp, "contree://image/{image}/read/{path}", resources.read_file)
222
+ register_resource_template(mcp, "contree://image/{image}/ls/{path}", resources.image_ls)
223
+ register_resource_template(mcp, "contree://image/{image}/lineage", resources.image_lineage)
224
+ register_resource_template(mcp, "contree://operations/instance/{operation_id}", resources.instance_operation)
225
+ register_resource_template(mcp, "contree://operations/import/{operation_id}", resources.import_operation)
226
+
227
+ # Register guide sections as static resources for discovery
228
+ for section, content in resources.SECTIONS.items():
229
+ mcp.add_resource(
230
+ resources.StaticResource(
231
+ content,
232
+ uri=AnyUrl(f"contree://guide/{section}"),
233
+ name=section,
234
+ title=f"Contree Guide: {section.replace('-', ' ').title()}",
235
+ description=f"Guide section on {section.replace('-', ' ')}",
236
+ mime_type="text/markdown",
237
+ )
238
+ )
239
+
240
+ return mcp
@@ -0,0 +1,35 @@
1
+ from enum import Enum
2
+ from pathlib import Path
3
+
4
+ import argclass
5
+
6
+
7
+ class ServerMode(str, Enum):
8
+ STDIO = "stdio"
9
+ HTTP = "http"
10
+
11
+
12
+ class HTTPGroup(argclass.Group):
13
+ listen: str = argclass.Argument(default="127.0.0.1")
14
+ port: int = argclass.Argument(default=9452)
15
+
16
+
17
+ class Cache(argclass.Group):
18
+ files: Path = Path("~") / ".cache" / "contree_mcp" / "files.db"
19
+ general: Path = Path("~") / ".cache" / "contree_mcp" / "cache.db"
20
+ prune_days: int = argclass.Argument(
21
+ default=60,
22
+ help="Delete cached entries older than this many days",
23
+ )
24
+
25
+
26
+ class Parser(argclass.Parser):
27
+ url: str = argclass.Argument(default="https://contree.dev", help="Contree API base URL")
28
+ token: str = argclass.Argument(secret=True, help="Contree API authentication token", required=True)
29
+ mode: ServerMode = argclass.EnumArgument(
30
+ ServerMode, default=ServerMode.STDIO, lowercase=True, help="Server transport mode"
31
+ )
32
+
33
+ log_level: int = argclass.LogLevel
34
+ http: HTTPGroup = HTTPGroup()
35
+ cache: Cache = Cache()
@@ -0,0 +1,2 @@
1
+ from .registry import RegistryAuth as RegistryAuth
2
+ from .registry import RegistryToken as RegistryToken
@@ -0,0 +1,236 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import webbrowser
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+ from types import MappingProxyType
9
+ from typing import cast
10
+
11
+ import httpx
12
+ from httpx import URL
13
+ from pydantic import BaseModel, Field
14
+
15
+ # Known registries and their PAT creation URLs
16
+ KNOWN_REGISTRIES: Mapping[str, URL] = MappingProxyType(
17
+ {
18
+ "docker.io": URL("https://app.docker.com/settings/personal-access-tokens"),
19
+ "ghcr.io": URL("https://github.com/settings/tokens?type=beta"),
20
+ "registry.gitlab.com": URL("https://gitlab.com/-/user_settings/personal_access_tokens"),
21
+ "gcr.io": URL("https://console.cloud.google.com/apis/credentials"),
22
+ "us.gcr.io": URL("https://console.cloud.google.com/apis/credentials"),
23
+ "eu.gcr.io": URL("https://console.cloud.google.com/apis/credentials"),
24
+ "asia.gcr.io": URL("https://console.cloud.google.com/apis/credentials"),
25
+ }
26
+ )
27
+
28
+ # Registry hostname aliases (some registries have different API hostnames)
29
+ REGISTRY_API_HOSTS: Mapping[str, str] = MappingProxyType({"docker.io": "registry-1.docker.io"})
30
+
31
+
32
+ @dataclass
33
+ class AuthEndpoint:
34
+ """Authentication endpoint discovered from registry /v2/ response."""
35
+
36
+ realm: str # Token endpoint URL
37
+ service: str # Service name for token request
38
+
39
+
40
+ @dataclass
41
+ class RegistryAuth:
42
+ """OCI registry authentication handler.
43
+
44
+ Provides methods for token discovery, validation, and exchange
45
+ using the OCI distribution spec.
46
+ """
47
+
48
+ registry: str
49
+ _endpoint: AuthEndpoint | None = field(default=None, repr=False)
50
+
51
+ @classmethod
52
+ def from_url(cls, registry_url: str) -> RegistryAuth:
53
+ """Create RegistryAuth from an image URL.
54
+
55
+ Note: oci:// scheme is transparently converted to docker:// (same protocol)
56
+
57
+ Examples:
58
+ - "docker://ghcr.io/org/image:tag" -> RegistryAuth(registry="ghcr.io")
59
+ - "oci://registry.gitlab.com/org/img" -> RegistryAuth(registry="registry.gitlab.com")
60
+ - "myorg/myimage:latest" -> RegistryAuth(registry="docker.io")
61
+ - "alpine" -> RegistryAuth(registry="docker.io")
62
+ """
63
+ # Normalize oci:// to docker://
64
+ if registry_url.startswith("oci://"):
65
+ registry_url = "docker://" + registry_url[6:]
66
+
67
+ # If no scheme, it's a bare image name -> docker.io
68
+ if "://" not in registry_url:
69
+ return cls(registry="docker.io")
70
+
71
+ # Use httpx.URL for parsing
72
+ url = httpx.URL(registry_url)
73
+ return cls(registry=url.host or "docker.io")
74
+
75
+ @property
76
+ def api_host(self) -> str:
77
+ """Get the API hostname for this registry.
78
+
79
+ Some registries (like docker.io) use a different hostname for API calls.
80
+ """
81
+ return REGISTRY_API_HOSTS.get(self.registry, self.registry)
82
+
83
+ @property
84
+ def pat_url(self) -> str | None:
85
+ """Get PAT creation URL for this registry.
86
+
87
+ Returns None if registry is not in the known list.
88
+ """
89
+ url = KNOWN_REGISTRIES.get(self.registry)
90
+ return str(url) if url is not None else None
91
+
92
+ @property
93
+ def is_known(self) -> bool:
94
+ """Check if this registry is in the known list."""
95
+ return self.registry in KNOWN_REGISTRIES
96
+
97
+ def open_pat_page(self) -> str | None:
98
+ """Open browser to PAT creation page.
99
+
100
+ Returns the URL if opened, None if registry is unknown.
101
+ """
102
+ url = self.pat_url
103
+ if url:
104
+ webbrowser.open(url)
105
+ return url
106
+
107
+ async def discover_endpoint(self) -> AuthEndpoint:
108
+ """Discover token endpoint from registry's /v2/ response.
109
+
110
+ Calls the registry's /v2/ endpoint and parses the Www-Authenticate header
111
+ to find the token realm and service. Caches the result for reuse.
112
+ """
113
+ if self._endpoint is not None:
114
+ return self._endpoint
115
+
116
+ url = f"https://{self.api_host}/v2/"
117
+
118
+ async with httpx.AsyncClient() as client:
119
+ response = await client.get(url, follow_redirects=True)
120
+
121
+ if response.status_code == 401:
122
+ www_auth = response.headers.get("Www-Authenticate", "")
123
+ endpoint = self._parse_www_authenticate(www_auth)
124
+ if endpoint:
125
+ self._endpoint = endpoint
126
+ return endpoint
127
+
128
+ # If we get 200, try catalog request to get auth info
129
+ if response.status_code == 200:
130
+ catalog_url = f"https://{self.api_host}/v2/_catalog"
131
+ catalog_response = await client.get(catalog_url, follow_redirects=True)
132
+ if catalog_response.status_code == 401:
133
+ www_auth = catalog_response.headers.get("Www-Authenticate", "")
134
+ endpoint = self._parse_www_authenticate(www_auth)
135
+ if endpoint:
136
+ self._endpoint = endpoint
137
+ return endpoint
138
+
139
+ raise ValueError(f"Could not discover auth endpoint for registry {self.registry}")
140
+
141
+ async def validate_token(self, username: str, token: str) -> bool:
142
+ """Validate token by requesting a token from the auth endpoint.
143
+
144
+ Args:
145
+ username: Registry username
146
+ token: Personal Access Token
147
+
148
+ Returns True if the token is valid and can be used for authentication.
149
+ """
150
+ try:
151
+ endpoint = await self.discover_endpoint()
152
+ except ValueError:
153
+ return False
154
+
155
+ async with httpx.AsyncClient() as client:
156
+ response = await client.get(
157
+ endpoint.realm,
158
+ params={"service": endpoint.service},
159
+ auth=httpx.BasicAuth(username, token),
160
+ )
161
+ return response.status_code == 200
162
+
163
+ async def get_bearer_token(self, username: str, token: str, scope: str) -> str | None:
164
+ """Exchange stored PAT for a scoped registry bearer token.
165
+
166
+ Args:
167
+ username: Registry username
168
+ token: Personal Access Token
169
+ scope: Scope string (e.g., "repository:myorg/myimage:pull")
170
+
171
+ Returns:
172
+ Bearer token for the specified scope, or None if authentication failed
173
+ """
174
+ endpoint = await self.discover_endpoint()
175
+
176
+ params = {
177
+ "service": endpoint.service,
178
+ "scope": scope,
179
+ }
180
+
181
+ async with httpx.AsyncClient() as client:
182
+ response = await client.get(
183
+ endpoint.realm,
184
+ params=params,
185
+ auth=httpx.BasicAuth(username, token),
186
+ )
187
+ if response.status_code != 200:
188
+ return None
189
+ return cast(str | None, response.json().get("token"))
190
+
191
+ @staticmethod
192
+ def _parse_www_authenticate(header: str) -> AuthEndpoint | None:
193
+ """Parse Www-Authenticate header to extract realm and service.
194
+
195
+ Example header:
196
+ Bearer realm="https://auth.docker.io/token",service="registry.docker.io"
197
+ """
198
+ if not header.startswith("Bearer "):
199
+ return None
200
+
201
+ # Extract realm
202
+ realm_match = re.search(r'realm="([^"]+)"', header)
203
+ if not realm_match:
204
+ return None
205
+ realm = realm_match.group(1)
206
+
207
+ # Extract service
208
+ service_match = re.search(r'service="([^"]+)"', header)
209
+ service = service_match.group(1) if service_match else ""
210
+
211
+ return AuthEndpoint(realm=realm, service=service)
212
+
213
+
214
+ class RegistryToken(BaseModel):
215
+ """Stored registry authentication token."""
216
+
217
+ registry: str
218
+ username: str
219
+ token: str
220
+ scopes: list[str] = Field(default_factory=lambda: ["pull"])
221
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
222
+
223
+
224
+ def normalize_registry_url(registry_url: str) -> str:
225
+ """Normalize registry URL to docker:// scheme.
226
+
227
+ oci:// is transparently converted to docker:// as they use the same protocol.
228
+ """
229
+ if registry_url.startswith("oci://"):
230
+ registry_url = "docker://" + registry_url[6:]
231
+
232
+ # Add docker:// if no scheme
233
+ if "://" not in registry_url:
234
+ registry_url = f"docker://docker.io/{registry_url}"
235
+
236
+ return registry_url