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.
- contree_mcp/__init__.py +0 -0
- contree_mcp/__main__.py +25 -0
- contree_mcp/app.py +240 -0
- contree_mcp/arguments.py +35 -0
- contree_mcp/auth/__init__.py +2 -0
- contree_mcp/auth/registry.py +236 -0
- contree_mcp/backend_types.py +301 -0
- contree_mcp/cache.py +208 -0
- contree_mcp/client.py +711 -0
- contree_mcp/context.py +53 -0
- contree_mcp/docs.py +1203 -0
- contree_mcp/file_cache.py +381 -0
- contree_mcp/prompts.py +238 -0
- contree_mcp/py.typed +0 -0
- contree_mcp/resources/__init__.py +17 -0
- contree_mcp/resources/guide.py +715 -0
- contree_mcp/resources/image_lineage.py +46 -0
- contree_mcp/resources/image_ls.py +32 -0
- contree_mcp/resources/import_operation.py +52 -0
- contree_mcp/resources/instance_operation.py +52 -0
- contree_mcp/resources/read_file.py +33 -0
- contree_mcp/resources/static.py +12 -0
- contree_mcp/server.py +77 -0
- contree_mcp/tools/__init__.py +39 -0
- contree_mcp/tools/cancel_operation.py +36 -0
- contree_mcp/tools/download.py +128 -0
- contree_mcp/tools/get_guide.py +54 -0
- contree_mcp/tools/get_image.py +30 -0
- contree_mcp/tools/get_operation.py +26 -0
- contree_mcp/tools/import_image.py +99 -0
- contree_mcp/tools/list_files.py +80 -0
- contree_mcp/tools/list_images.py +50 -0
- contree_mcp/tools/list_operations.py +46 -0
- contree_mcp/tools/read_file.py +47 -0
- contree_mcp/tools/registry_auth.py +71 -0
- contree_mcp/tools/registry_token_obtain.py +80 -0
- contree_mcp/tools/rsync.py +46 -0
- contree_mcp/tools/run.py +97 -0
- contree_mcp/tools/set_tag.py +31 -0
- contree_mcp/tools/upload.py +50 -0
- contree_mcp/tools/wait_operations.py +79 -0
- contree_mcp-0.1.0.dist-info/METADATA +450 -0
- contree_mcp-0.1.0.dist-info/RECORD +46 -0
- contree_mcp-0.1.0.dist-info/WHEEL +4 -0
- contree_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|
contree_mcp/tools/run.py
ADDED
|
@@ -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)
|