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
@@ -0,0 +1,99 @@
1
+ from contree_mcp.auth import RegistryAuth, RegistryToken
2
+ from contree_mcp.backend_types import OperationResponse
3
+ from contree_mcp.context import CLIENT
4
+
5
+
6
+ class RegistryAuthenticationError(Exception):
7
+ """Raised when registry authentication is required but not found."""
8
+
9
+ def __init__(self, registry: str):
10
+ self.registry = registry
11
+ super().__init__(
12
+ f"Not authenticated with '{registry}'. "
13
+ f"Run registry_token_obtain(registry_url='...') first to open the browser, "
14
+ f"then registry_auth(registry_url='...', username='...', token='...') to authenticate."
15
+ )
16
+
17
+
18
+ async def import_image(
19
+ registry_url: str,
20
+ tag: str | None = None,
21
+ wait: bool = True,
22
+ i_accept_that_anonymous_access_might_be_rate_limited: bool = False,
23
+ ) -> OperationResponse | dict[str, str]:
24
+ """
25
+ Import OCI container image from registry (e.g., Docker Hub). Spawns microVM.
26
+
27
+ TL;DR:
28
+ - PURPOSE: Import a base image only when nothing suitable exists locally
29
+ - AUTH: Requires prior authentication via registry_auth() or anonymous access
30
+ - REUSE: Always check list_images first - reuse existing tags/UUIDs when possible
31
+ - COST: Highest-cost operation; can take dozens of minutes and incur microVM costs
32
+
33
+ AUTHENTICATION:
34
+ Before importing, you must authenticate with the registry:
35
+ 1. Call registry_token_obtain(registry_url="...") to open browser for PAT creation
36
+ 2. Wait for user to provide the token
37
+ 3. Call registry_auth(registry_url="...", username="...", token="...") to store credentials
38
+
39
+ Anonymous access is possible but discouraged (registry provider rate limits).
40
+
41
+ USAGE:
42
+ - Avoid import_image when you can build on an existing base
43
+ (e.g., Ubuntu + apt/pip with disposable=false + set_tag)
44
+ - registry_url must be full URL with protocol prefix:
45
+ - docker://docker.io/library/alpine:latest
46
+ - docker://docker.io/library/python:3.11-slim
47
+ - docker://docker.io/library/ubuntu:22.04
48
+ - docker://ghcr.io/owner/image:tag
49
+ - Use returned UUID directly for subsequent operations
50
+ - Only assign tags to frequently-used images
51
+ - Tag format: `{scope}/{purpose}/{base}` where base includes its tag
52
+
53
+ RETURNS: result_image UUID, result_tag (if assigned)
54
+ - operation_id returned when wait=false
55
+
56
+ GUIDES:
57
+ - [ESSENTIAL] contree://guide/async - Async execution with wait=false
58
+ - [USEFUL] contree://guide/tagging - Agent tagging convention
59
+ """
60
+ client = CLIENT.get()
61
+
62
+ # Parse registry from URL
63
+ auth = RegistryAuth.from_url(registry_url)
64
+
65
+ username: str | None = None
66
+ password: str | None = None
67
+
68
+ # Look up credentials from cache
69
+ entry = await client.cache.get(kind="registry_token", key=auth.registry)
70
+
71
+ if entry is not None:
72
+ # Extract credentials from cache entry
73
+ registry_token = RegistryToken.model_validate(entry.data)
74
+
75
+ # Revalidate token before use (tokens can expire)
76
+ is_valid = await auth.validate_token(registry_token.username, registry_token.token)
77
+ if is_valid:
78
+ username = registry_token.username
79
+ password = registry_token.token
80
+ else:
81
+ # Remove invalid token from cache
82
+ await client.cache.delete(kind="registry_token", key=auth.registry)
83
+
84
+ # If no valid credentials and anonymous not allowed, raise error
85
+ if username is None and not i_accept_that_anonymous_access_might_be_rate_limited:
86
+ raise RegistryAuthenticationError(auth.registry)
87
+
88
+ operation_id = await client.import_image(
89
+ registry_url=registry_url,
90
+ tag=tag,
91
+ username=username,
92
+ password=password,
93
+ )
94
+
95
+ if wait:
96
+ # Client handles lineage caching automatically via _cache_lineage
97
+ return await client.wait_for_operation(operation_id)
98
+
99
+ return {"operation_id": operation_id}
@@ -0,0 +1,80 @@
1
+ from pydantic import BaseModel
2
+
3
+ from contree_mcp.context import CLIENT
4
+
5
+
6
+ class FileEntry(BaseModel):
7
+ """File entry in directory listing."""
8
+
9
+ name: str
10
+ path: str
11
+ type: str # "file", "directory", "symlink"
12
+ size: int
13
+ mode: str | None = None
14
+ target: str | None = None # For symlinks
15
+
16
+
17
+ class ListFilesOutput(BaseModel):
18
+ """Output of list_files tool."""
19
+
20
+ path: str
21
+ count: int
22
+ files: list[FileEntry]
23
+
24
+
25
+ async def list_files(image: str, path: str = "/") -> ListFilesOutput:
26
+ """
27
+ List files and directories in a container image. Free (no VM).
28
+
29
+ TL;DR:
30
+ - PURPOSE: Inspect container filesystem without starting a VM
31
+ - ADVANTAGE: Instant results, no VM cost - use before run
32
+ - COST: Free (no VM)
33
+
34
+ USAGE:
35
+ - Explore image structure before running commands
36
+ - Verify expected files exist at target paths
37
+ - Check file permissions and symlink targets
38
+ - Prefer over run("ls") for simple listings
39
+
40
+ RETURNS: path, count, files[] with name, path, type, size, mode, target
41
+
42
+ GUIDES:
43
+ - [USEFUL] contree://guide/reference - Tool reference and resources
44
+ """
45
+
46
+ client = CLIENT.get()
47
+ image_uuid = await client.resolve_image(image)
48
+
49
+ # Normalize path
50
+ if not path.startswith("/"):
51
+ path = "/" + path
52
+ if path == "/.":
53
+ path = "/"
54
+
55
+ listing = await client.list_directory(image_uuid, path)
56
+
57
+ # Handle text response (shouldn't happen with as_text=False default)
58
+ if isinstance(listing, str):
59
+ return ListFilesOutput(path=path, count=0, files=[])
60
+
61
+ files = []
62
+ for f in listing.files:
63
+ if f.is_symlink:
64
+ file_type = "symlink"
65
+ elif f.is_dir:
66
+ file_type = "directory"
67
+ else:
68
+ file_type = "file"
69
+
70
+ entry = FileEntry(
71
+ name=f.path.rsplit("/", 1)[-1] if "/" in f.path else f.path,
72
+ path=f.path,
73
+ type=file_type,
74
+ size=f.size,
75
+ mode=oct(f.mode) if f.mode is not None else None,
76
+ target=f.symlink_to if f.is_symlink and f.symlink_to else None,
77
+ )
78
+ files.append(entry)
79
+
80
+ return ListFilesOutput(path=listing.path, count=len(files), files=files)
@@ -0,0 +1,50 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+ from contree_mcp.backend_types import Image
4
+ from contree_mcp.context import CLIENT
5
+
6
+
7
+ class ListImagesOutput(BaseModel):
8
+ images: list[Image] = Field(description="List of images")
9
+
10
+
11
+ async def list_images(
12
+ limit: int = 100,
13
+ offset: int = 0,
14
+ tagged: bool | None = None,
15
+ tag_prefix: str | None = None,
16
+ since: str | None = None,
17
+ until: str | None = None,
18
+ ) -> ListImagesOutput:
19
+ """
20
+ List available container images. Free (no VM).
21
+
22
+ TL;DR:
23
+ - PURPOSE: Find existing images before importing new ones
24
+ - FIRST STEP: Use this before import_image to avoid the most expensive operation (can take dozens of minutes)
25
+ - FILTER: Use tag_prefix to find specific image types
26
+ - COST: Free (no VM)
27
+
28
+ USAGE:
29
+ - Browse available images to find base images for commands
30
+ - Filter by tag prefix to find specific image types
31
+ - Use returned UUIDs directly in run
32
+ - Tag prefixes are typically `common/` or `<project>/`
33
+ - Examples: tag_prefix="common/python", tag_prefix="myproj/"
34
+
35
+ RETURNS: images[] with uuid, tag, created_at
36
+
37
+ GUIDES:
38
+ - [USEFUL] contree://guide/tagging - Agent tagging convention
39
+ """
40
+
41
+ client = CLIENT.get()
42
+ images = await client.list_images(
43
+ limit=limit,
44
+ offset=offset,
45
+ tagged=tagged,
46
+ tag_prefix=tag_prefix,
47
+ since=since,
48
+ until=until,
49
+ )
50
+ return ListImagesOutput(images=[Image(uuid=img.uuid, tag=img.tag, created_at=img.created_at) for img in images])
@@ -0,0 +1,46 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+ from contree_mcp.backend_types import OperationKind, OperationStatus, OperationSummary
4
+ from contree_mcp.context import CLIENT
5
+
6
+
7
+ class ListOperationsOutput(BaseModel):
8
+ operations: list[OperationSummary] = Field(description="List of operations")
9
+
10
+
11
+ async def list_operations(
12
+ limit: int = 100,
13
+ status: OperationStatus | None = None,
14
+ kind: OperationKind | None = None,
15
+ since: str | None = None,
16
+ ) -> ListOperationsOutput:
17
+ """
18
+ List operations (command executions and image imports). Free (no VM).
19
+
20
+ TL;DR:
21
+ - PURPOSE: Monitor async operations launched with wait=false
22
+ - FILTER: Use status="running" to find active operations
23
+ - COST: Free (no VM)
24
+
25
+ USAGE:
26
+ - Monitor running operations
27
+ - Review history of command executions
28
+ - Filter by status (pending, running, success, failed, cancelled)
29
+ - Filter by kind (image_import, instance)
30
+
31
+ RETURNS: operations[] with uuid, kind, state, created_at
32
+
33
+ GUIDES:
34
+ - [ESSENTIAL] contree://guide/async - Async execution and polling
35
+ """
36
+
37
+ client = CLIENT.get()
38
+
39
+ operations = await client.list_operations(
40
+ limit=limit,
41
+ status=status,
42
+ kind=kind,
43
+ since=since,
44
+ )
45
+
46
+ return ListOperationsOutput(operations=operations)
@@ -0,0 +1,47 @@
1
+ import base64
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from contree_mcp.context import CLIENT
6
+
7
+
8
+ class ReadFileOutput(BaseModel):
9
+ path: str
10
+ content: str
11
+ encoding: str = "utf-8"
12
+ bytes_size: int
13
+
14
+
15
+ async def read_file(image: str, path: str) -> ReadFileOutput:
16
+ """
17
+ Read a file from a container image. Free (no VM).
18
+
19
+ TL;DR:
20
+ - PURPOSE: Inspect file contents without starting a VM
21
+ - ADVANTAGE: Instant access to configs and scripts - no VM cost
22
+ - COST: Free (no VM)
23
+
24
+ USAGE:
25
+ - Inspect configuration files to understand image setup
26
+ - Review scripts before execution to verify behavior
27
+ - Check expected content without downloading to local filesystem
28
+ - Prefer over run("cat") when you just need file contents
29
+
30
+ RETURNS: path, content, size, is_text
31
+
32
+ GUIDES:
33
+ - [USEFUL] contree://guide/reference - Tool reference and resources
34
+ """
35
+
36
+ client = CLIENT.get()
37
+ image_uuid = await client.resolve_image(image)
38
+ content = await client.read_file(image_uuid, path)
39
+
40
+ try:
41
+ content_str = content.decode("utf-8")
42
+ encoding = "utf-8"
43
+ except UnicodeDecodeError:
44
+ content_str = base64.b64encode(content).decode("utf-8")
45
+ encoding = "base64"
46
+
47
+ return ReadFileOutput(path=path, content=content_str, encoding=encoding, bytes_size=len(content))
@@ -0,0 +1,71 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from contree_mcp.auth import RegistryAuth, RegistryToken
6
+ from contree_mcp.context import CLIENT
7
+
8
+
9
+ class RegistryAuthResponse(BaseModel):
10
+ """Response from registry_auth tool."""
11
+
12
+ status: Literal["success", "error"]
13
+ registry: str
14
+ message: str
15
+
16
+
17
+ async def registry_auth(
18
+ registry_url: str,
19
+ username: str,
20
+ token: str,
21
+ ) -> RegistryAuthResponse:
22
+ """
23
+ Authenticate with a container registry via Personal Access Token.
24
+
25
+ TL;DR:
26
+ - PURPOSE: Validate and store registry credentials
27
+ - VALIDATION: Tests credentials via OCI /v2/ API
28
+ - STORAGE: Credentials persisted in local cache
29
+
30
+ URL PARSING:
31
+ - "docker://ghcr.io/org/image" -> ghcr.io
32
+ - "oci://registry.gitlab.com/org/image" -> registry.gitlab.com
33
+ - "alpine" or "library/alpine" -> docker.io (implicit)
34
+
35
+ USAGE:
36
+ 1. Call registry_token_obtain(registry_url="...") -> opens browser
37
+ 2. User creates read-only PAT in registry web UI
38
+ 3. Call registry_auth(registry_url="...", username="...", token="...") -> validates and stores
39
+
40
+ RETURNS: status, message, registry (parsed hostname)
41
+ """
42
+ auth = RegistryAuth.from_url(registry_url)
43
+
44
+ # Validate credentials via OCI API
45
+ is_valid = await auth.validate_token(username, token)
46
+ if not is_valid:
47
+ return RegistryAuthResponse(
48
+ status="error",
49
+ registry=auth.registry,
50
+ message=(f"Invalid credentials for '{auth.registry}'. Please verify your username and PAT."),
51
+ )
52
+
53
+ # Store credentials in cache
54
+ client = CLIENT.get()
55
+ registry_token = RegistryToken(
56
+ registry=auth.registry,
57
+ username=username,
58
+ token=token,
59
+ scopes=["pull"],
60
+ )
61
+ await client.cache.put(
62
+ kind="registry_token",
63
+ key=auth.registry,
64
+ data=registry_token,
65
+ )
66
+
67
+ return RegistryAuthResponse(
68
+ status="success",
69
+ registry=auth.registry,
70
+ message=f"Authenticated with '{auth.registry}' as '{username}' successfully.",
71
+ )
@@ -0,0 +1,80 @@
1
+ import webbrowser
2
+ from typing import Literal
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from contree_mcp.auth import RegistryAuth
7
+
8
+
9
+ class RegistryTokenObtainResponse(BaseModel):
10
+ """Response from registry_token_obtain tool."""
11
+
12
+ status: Literal["success", "error"]
13
+ registry: str
14
+ url: str = ""
15
+ message: str
16
+ agent_instruction: str = ""
17
+
18
+
19
+ async def registry_token_obtain(
20
+ registry_url: str,
21
+ ) -> RegistryTokenObtainResponse:
22
+ """
23
+ Open browser to create a Personal Access Token for a container registry.
24
+
25
+ TL;DR:
26
+ - PURPOSE: Guide user to create a read-only PAT for the registry
27
+ - KNOWN REGISTRIES: Opens correct PAT creation page
28
+ - UNKNOWN REGISTRIES: Returns error with instructions
29
+
30
+ URL PARSING:
31
+ - "docker://ghcr.io/org/image" -> ghcr.io
32
+ - "oci://registry.gitlab.com/org/image" -> registry.gitlab.com
33
+ - "alpine" or "library/alpine" -> docker.io (implicit)
34
+
35
+ KNOWN REGISTRIES:
36
+ - docker.io -> Docker Hub PAT page
37
+ - ghcr.io -> GitHub fine-grained tokens page
38
+ - registry.gitlab.com -> GitLab PAT page
39
+ - gcr.io -> Google Cloud credentials page
40
+
41
+ RETURNS: status, message, url (if opened), registry (parsed hostname)
42
+ ERRORS: "Unknown registry. Please consult registry docs for token creation."
43
+
44
+ AGENT INSTRUCTIONS:
45
+ After calling this tool, you MUST STOP and wait for the user to:
46
+ 1. Create a PAT in the opened browser page
47
+ 2. Provide the token back to you
48
+ Then call registry_auth() with the provided token.
49
+ DO NOT proceed automatically - user interaction is required.
50
+ """
51
+ auth = RegistryAuth.from_url(registry_url)
52
+
53
+ if not auth.is_known:
54
+ return RegistryTokenObtainResponse(
55
+ status="error",
56
+ registry=auth.registry,
57
+ message=(
58
+ f"Unknown registry '{auth.registry}'. Please consult the registry documentation for token creation."
59
+ ),
60
+ )
61
+
62
+ pat_url = auth.pat_url
63
+ if pat_url:
64
+ webbrowser.open(str(pat_url))
65
+
66
+ return RegistryTokenObtainResponse(
67
+ status="success",
68
+ registry=auth.registry,
69
+ url=str(pat_url) if pat_url else "",
70
+ message=(
71
+ f"Browser opened to {pat_url}. "
72
+ f"Create a read-only PAT, then provide your username and token. "
73
+ f"After receiving the token, call "
74
+ f"registry_auth(registry_url='{registry_url}', username='<username>', token='<token>')."
75
+ ),
76
+ agent_instruction=(
77
+ "STOP HERE. Wait for user to create PAT and provide the token. "
78
+ "Do not proceed until user gives you the token."
79
+ ),
80
+ )
@@ -0,0 +1,46 @@
1
+ from pathlib import Path
2
+
3
+ from contree_mcp.context import CLIENT, FILES_CACHE
4
+
5
+
6
+ async def rsync(
7
+ source: str,
8
+ destination: str,
9
+ exclude: list[str] | None = None,
10
+ ) -> int:
11
+ """
12
+ Sync local files to Contree for use in container instances. Free (no VM).
13
+
14
+ TL;DR:
15
+ - PURPOSE: Prepare files for run injection
16
+ - WORKFLOW: rsync -> get directory_state_id -> pass to run
17
+ - CACHE: Smart caching uploads only changed files
18
+ - EXCLUDE: Always exclude __pycache__, .git, node_modules, .venv
19
+
20
+ USAGE:
21
+ - source: Absolute path to local directory (~ expansion supported)
22
+ - Sync directory: source="/path/to/project", destination="/app"
23
+ - Exclude patterns: exclude=["__pycache__", "*.pyc", ".git"]
24
+
25
+ RETURNS: directory_state_id (int) for run tool
26
+
27
+ GUIDES:
28
+ - [ESSENTIAL] contree://guide/quickstart - File sync + execute patterns
29
+ """
30
+
31
+ client = CLIENT.get()
32
+ files_cache = FILES_CACHE.get()
33
+
34
+ source_path = Path(source).expanduser()
35
+ if not source_path.is_absolute():
36
+ raise ValueError(f"source must be an absolute path, got: {source}")
37
+
38
+ destination = destination.rstrip("/")
39
+ exclude = exclude or []
40
+
41
+ return await files_cache.sync_directory(
42
+ client=client,
43
+ path=source_path,
44
+ destination=destination,
45
+ excludes=exclude,
46
+ )
@@ -0,0 +1,97 @@
1
+ from typing import Any
2
+
3
+ from contree_mcp.backend_types import OperationResponse
4
+ from contree_mcp.context import CLIENT, FILES_CACHE
5
+
6
+
7
+ async def run(
8
+ command: str,
9
+ image: str,
10
+ shell: bool = True,
11
+ env: dict[str, str] | None = None,
12
+ cwd: str = "/root",
13
+ timeout: int = 30,
14
+ disposable: bool = True,
15
+ stdin: str | None = None,
16
+ directory_state_id: int | None = None,
17
+ files: dict[str, str] | None = None,
18
+ wait: bool = True,
19
+ truncate_output_at: int = 8000,
20
+ ) -> OperationResponse | dict[str, str]:
21
+ """
22
+ Execute command in isolated container. Spawns microVM.
23
+ Returns string with operation_id when wait=false or detailed result when wait=true.
24
+
25
+ TL;DR:
26
+ - PURPOSE: Run code in sandboxed environment with full root access
27
+ - IMAGE POLICY: Prefer existing images; import_image is the most expensive operation (can take dozens of minutes)
28
+ and should be a last resort. If you need Python on Ubuntu, install it once with disposable=false and
29
+ set_tag for reuse.
30
+ - WORKFLOW: Use rsync or upload first to inject files, then call run tool
31
+ - COST: Spawns microVM (~2-5s startup)
32
+
33
+ USAGE:
34
+ - Use list_images to find existing tags/UUIDs before import_image
35
+ - Run shell commands with stdout/stderr capture
36
+ - Chain commands using result_image from disposable=false
37
+ - If you intend reuse, tag result_image using the convention:
38
+ `{scope}/{purpose}/{base}` (base includes tag, e.g. python:3.11-slim)
39
+ - Launch async with wait=false, poll with get_operation or wait_operations
40
+ - Use env parameter for environment variables, not shell export
41
+
42
+ RETURNS: stdout, stderr, exit_code, result_image (when disposable=false)
43
+ - Use result_image UUID to chain subsequent commands
44
+ - operation_id returned when wait=false
45
+
46
+ GUIDES:
47
+ - [ESSENTIAL] contree://guide/quickstart - File sync + execute patterns
48
+ - [ESSENTIAL] contree://guide/async - Parallel execution
49
+ - [USEFUL] contree://guide/state - When to save vs discard
50
+ """
51
+ client = CLIENT.get()
52
+ files_cache = FILES_CACHE.get()
53
+
54
+ image_uuid = await client.resolve_image(image)
55
+
56
+ # Load files from directory state if provided
57
+ spawn_files: dict[str, dict[str, Any]] | None = None
58
+ if directory_state_id:
59
+ ds = await files_cache.get_directory_state(directory_state_id)
60
+ if ds is None:
61
+ raise ValueError(f"Directory state not found: {directory_state_id}")
62
+
63
+ ds_files = await files_cache.get_directory_state_files(directory_state_id)
64
+ if not ds_files:
65
+ raise ValueError(f"Directory state has no files: {directory_state_id}")
66
+
67
+ spawn_files = {}
68
+ for f in ds_files:
69
+ spawn_files[f.target_path] = {
70
+ "uuid": f.file_uuid,
71
+ "mode": oct(f.target_mode),
72
+ }
73
+
74
+ # Add direct file UUIDs (from upload)
75
+ if files:
76
+ if spawn_files is None:
77
+ spawn_files = {}
78
+ for path, uuid in files.items():
79
+ spawn_files[path] = {"uuid": uuid, "mode": "0o644"}
80
+
81
+ # Use spawn_instance when files are provided
82
+ # Note: Client handles lineage caching automatically via _cache_lineage
83
+ operation_id = await client.spawn_instance(
84
+ command=command,
85
+ image=image_uuid,
86
+ shell=shell,
87
+ env=env,
88
+ cwd=cwd,
89
+ timeout=timeout,
90
+ disposable=disposable,
91
+ stdin=stdin,
92
+ files=spawn_files,
93
+ truncate_output_at=truncate_output_at,
94
+ )
95
+ if wait:
96
+ return await client.wait_for_operation(operation_id)
97
+ return {"operation_id": operation_id}
@@ -0,0 +1,31 @@
1
+ from contree_mcp.backend_types import Image
2
+ from contree_mcp.context import CLIENT
3
+
4
+
5
+ async def set_tag(image_uuid: str, tag: str | None = None) -> Image:
6
+ """
7
+ Set or remove tag for container image. Free (no VM).
8
+ TL;DR:
9
+ - PURPOSE: Tag frequently-used images for reuse across sessions
10
+ - REMOVE: Omit tag parameter to remove existing tag
11
+ - COST: Free (no VM)
12
+
13
+ USAGE:
14
+ - Assign memorable name to frequently-used base images
15
+ - Omit tag to remove existing tag from image
16
+ - Prefer UUIDs directly for one-off operations
17
+ - Tag format: `{scope}/{purpose}/{base}` where base includes its tag
18
+ - Scope: `common` for reusable deps, or project name for project-specific
19
+ - Purpose: describe what you added (e.g., python-ml, web-deps)
20
+
21
+ RETURNS: uuid, tag, created_at
22
+
23
+ GUIDES:
24
+ - [ESSENTIAL] contree://guide/tagging - Agent tagging convention
25
+ """
26
+ client = CLIENT.get()
27
+ if tag:
28
+ img = await client.tag_image(image_uuid=image_uuid, tag=tag)
29
+ else:
30
+ img = await client.untag_image(image_uuid=image_uuid)
31
+ return Image(uuid=img.uuid, tag=img.tag, created_at=img.created_at)