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,46 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from urllib.parse import unquote
|
|
3
|
+
|
|
4
|
+
from contree_mcp.context import CLIENT
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def image_lineage(image: str) -> str:
|
|
8
|
+
"""View image parent-child relationships and history.
|
|
9
|
+
|
|
10
|
+
View image parent-child relationships and history. Free (no VM).
|
|
11
|
+
|
|
12
|
+
URI: contree://image/{image}/lineage
|
|
13
|
+
|
|
14
|
+
Returns lineage information including:
|
|
15
|
+
- parent: Immediate parent image
|
|
16
|
+
- children: Direct children of this image
|
|
17
|
+
- ancestors: Full parent chain up to root
|
|
18
|
+
- root: Root imported image
|
|
19
|
+
- depth: Number of ancestors
|
|
20
|
+
- is_known: Whether the image is in our lineage database
|
|
21
|
+
- data: Stored metadata (command, operation_id, etc.)
|
|
22
|
+
|
|
23
|
+
Example: contree://image/abc-123-def/lineage
|
|
24
|
+
"""
|
|
25
|
+
cache = CLIENT.get().cache
|
|
26
|
+
# URL-decode image since it may contain encoded characters
|
|
27
|
+
decoded_image = unquote(image)
|
|
28
|
+
|
|
29
|
+
entry = await cache.get("image", decoded_image)
|
|
30
|
+
ancestors = await cache.get_ancestors("image", decoded_image)
|
|
31
|
+
children = await cache.get_children("image", decoded_image)
|
|
32
|
+
|
|
33
|
+
# Extract root from ancestors (last one) or self if no ancestors
|
|
34
|
+
root = ancestors[-1].key if ancestors else (entry.key if entry else None)
|
|
35
|
+
|
|
36
|
+
lineage_data = {
|
|
37
|
+
"image": decoded_image,
|
|
38
|
+
"parent": entry.data.get("parent_image") if entry else None,
|
|
39
|
+
"children": [c.key for c in children],
|
|
40
|
+
"ancestors": [a.key for a in ancestors],
|
|
41
|
+
"root": root,
|
|
42
|
+
"depth": len(ancestors),
|
|
43
|
+
"is_known": entry is not None,
|
|
44
|
+
"data": dict(entry.data) if entry else None,
|
|
45
|
+
}
|
|
46
|
+
return json.dumps(lineage_data, indent=2)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from urllib.parse import unquote
|
|
2
|
+
|
|
3
|
+
from contree_mcp.context import CLIENT
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def image_ls(image: str, path: str) -> str:
|
|
7
|
+
"""List files and directories in a container image.
|
|
8
|
+
|
|
9
|
+
List files and directories in a container image. Free (no VM).
|
|
10
|
+
Output is ls -alh style text format.
|
|
11
|
+
|
|
12
|
+
URI: contree://image/{image}/ls/{path}
|
|
13
|
+
|
|
14
|
+
Where:
|
|
15
|
+
- image: Image UUID or tag prefixed with "tag:" (e.g., "tag:alpine:latest")
|
|
16
|
+
- path: Directory path without leading slash (e.g., "etc" or "usr/bin")
|
|
17
|
+
- For root directory, use path "."
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
- contree://image/abc-123-def/ls/.
|
|
21
|
+
- contree://image/abc-123-def/ls/etc
|
|
22
|
+
- contree://image/abc-123-def/ls/usr/local/bin
|
|
23
|
+
- contree://image/tag:alpine:latest/ls/etc
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
client = CLIENT.get()
|
|
27
|
+
# URL-decode image and path since they may contain encoded characters
|
|
28
|
+
decoded_image = unquote(image)
|
|
29
|
+
decoded_path = unquote(path)
|
|
30
|
+
image_uuid = await client.resolve_image(decoded_image)
|
|
31
|
+
dir_path = "/" if decoded_path in (".", "") else "/" + decoded_path
|
|
32
|
+
return await client.list_directory_text(image_uuid, dir_path)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from contree_mcp.backend_types import ImportImageMetadata, OperationKind
|
|
2
|
+
from contree_mcp.context import CLIENT
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
async def import_operation(operation_id: str) -> str:
|
|
6
|
+
"""
|
|
7
|
+
Read image import operation details. Free (no VM).
|
|
8
|
+
|
|
9
|
+
Return text in format:
|
|
10
|
+
```
|
|
11
|
+
STATE: SUCCESS
|
|
12
|
+
RESULT_IMAGE: <uuid>
|
|
13
|
+
RESULT_TAG: latest
|
|
14
|
+
REGISTRY_URL: registry.example.com/repo/image:tag
|
|
15
|
+
ERROR:
|
|
16
|
+
multiline error message if any
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Strings might absent in case it's not applicable.
|
|
20
|
+
|
|
21
|
+
URI: contree://operations/import/{operation_id}
|
|
22
|
+
|
|
23
|
+
Example: contree://operations/import/op-abc-123-def
|
|
24
|
+
Returns:
|
|
25
|
+
```
|
|
26
|
+
STATE: SUCCESS
|
|
27
|
+
RESULT_IMAGE: 550e8400-e29b-41d4-a716-446655440000
|
|
28
|
+
RESULT_TAG: latest
|
|
29
|
+
REGISTRY_URL: registry.example.com/repo/image:tag
|
|
30
|
+
"""
|
|
31
|
+
client = CLIENT.get()
|
|
32
|
+
op = await client.get_operation(operation_id)
|
|
33
|
+
if op.kind != OperationKind.IMAGE_IMPORT:
|
|
34
|
+
raise ValueError(f"Operation {operation_id} is not an import operation (kind={op.kind})")
|
|
35
|
+
|
|
36
|
+
result = f"STATE: {op.status.value}"
|
|
37
|
+
|
|
38
|
+
if op.result and op.result.image:
|
|
39
|
+
result += f"\nRESULT_IMAGE: {op.result.image}"
|
|
40
|
+
if op.result and op.result.tag:
|
|
41
|
+
result += f"\nRESULT_TAG: {op.result.tag}"
|
|
42
|
+
|
|
43
|
+
# Extract registry URL from metadata
|
|
44
|
+
if isinstance(op.metadata, ImportImageMetadata):
|
|
45
|
+
registry_url = str(op.metadata.registry.url) if op.metadata.registry else None
|
|
46
|
+
if registry_url:
|
|
47
|
+
result += f"\nREGISTRY_URL: {registry_url}"
|
|
48
|
+
|
|
49
|
+
if op.error:
|
|
50
|
+
result += f"\nERROR:\n{op.error}"
|
|
51
|
+
|
|
52
|
+
return result
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from contree_mcp.backend_types import InstanceMetadata, OperationKind
|
|
4
|
+
from contree_mcp.context import CLIENT
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def instance_operation(operation_id: str) -> str:
|
|
8
|
+
"""Read instance (command execution) operation details.
|
|
9
|
+
|
|
10
|
+
Read instance (command execution) operation details. Free (no VM).
|
|
11
|
+
|
|
12
|
+
URI: contree://operations/instance/{operation_id}
|
|
13
|
+
|
|
14
|
+
Returns cached operation data including:
|
|
15
|
+
- state: Operation state (SUCCESS, FAILED, etc.)
|
|
16
|
+
- exit_code: Command exit code
|
|
17
|
+
- stdout/stderr: Command output
|
|
18
|
+
- result_image: Output image UUID (if disposable=false)
|
|
19
|
+
- resources: CPU/memory usage statistics
|
|
20
|
+
|
|
21
|
+
Example: contree://operations/instance/op-abc-123-def
|
|
22
|
+
"""
|
|
23
|
+
client = CLIENT.get()
|
|
24
|
+
# Use get_operation which checks cache first, then fetches from API
|
|
25
|
+
op = await client.get_operation(operation_id)
|
|
26
|
+
|
|
27
|
+
if op.kind != OperationKind.INSTANCE:
|
|
28
|
+
raise ValueError(f"Operation {operation_id} is not an instance operation (kind={op.kind})")
|
|
29
|
+
|
|
30
|
+
result_data: dict[str, object] = {
|
|
31
|
+
"state": op.status.value,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if op.error:
|
|
35
|
+
result_data["error"] = op.error
|
|
36
|
+
|
|
37
|
+
if op.result:
|
|
38
|
+
result_data["result_image"] = op.result.image
|
|
39
|
+
if op.result.tag:
|
|
40
|
+
result_data["result_tag"] = op.result.tag
|
|
41
|
+
|
|
42
|
+
# Extract instance-specific metadata
|
|
43
|
+
if isinstance(op.metadata, InstanceMetadata) and op.metadata.result:
|
|
44
|
+
instance_result = op.metadata.result
|
|
45
|
+
result_data["exit_code"] = instance_result.state.exit_code
|
|
46
|
+
result_data["timed_out"] = instance_result.state.timed_out
|
|
47
|
+
result_data["stdout"] = instance_result.stdout.text() if instance_result.stdout else ""
|
|
48
|
+
result_data["stderr"] = instance_result.stderr.text() if instance_result.stderr else ""
|
|
49
|
+
if instance_result.resources:
|
|
50
|
+
result_data["resources"] = instance_result.resources.model_dump()
|
|
51
|
+
|
|
52
|
+
return json.dumps(result_data, indent=2)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from urllib.parse import unquote
|
|
3
|
+
|
|
4
|
+
from contree_mcp.context import CLIENT
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def read_file(image: str, path: str) -> str:
|
|
8
|
+
"""Read a file from a container image.
|
|
9
|
+
|
|
10
|
+
Read text files, config files, scripts, etc. without incurring VM costs.
|
|
11
|
+
Binary files will be returned as BASE64-encoded strings prefixed with "base64:".
|
|
12
|
+
|
|
13
|
+
URI: contree://image/{image}/read/{path}
|
|
14
|
+
|
|
15
|
+
Where:
|
|
16
|
+
- image: Image UUID or tag prefixed with "tag:" (e.g., "tag:alpine:latest")
|
|
17
|
+
- path: File path without leading slash (e.g., "etc/passwd")
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
- contree://image/abc-123-def/read/etc/passwd
|
|
21
|
+
- contree://image/abc-123-def/read/usr/local/bin/python
|
|
22
|
+
- contree://image/tag:alpine:latest/read/etc/alpine-release
|
|
23
|
+
"""
|
|
24
|
+
client = CLIENT.get()
|
|
25
|
+
image_uuid = await client.resolve_image(image)
|
|
26
|
+
file_path = "/" + unquote(path).lstrip("/")
|
|
27
|
+
content = await client.read_file(image_uuid, file_path)
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
return content.decode("utf-8")
|
|
31
|
+
except (UnicodeDecodeError, AttributeError):
|
|
32
|
+
b64_content = base64.b64encode(content).decode("utf-8")
|
|
33
|
+
return f"base64:{b64_content}"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from mcp.server.fastmcp.resources import Resource
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StaticResource(Resource):
|
|
7
|
+
def __init__(self, content: str, /, **data: Any) -> None:
|
|
8
|
+
super().__init__(**data)
|
|
9
|
+
self._content = content
|
|
10
|
+
|
|
11
|
+
async def read(self) -> str | bytes:
|
|
12
|
+
return self._content
|
contree_mcp/server.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import contextvars
|
|
2
|
+
import logging
|
|
3
|
+
from contextlib import AsyncExitStack
|
|
4
|
+
from functools import partial
|
|
5
|
+
|
|
6
|
+
import uvicorn
|
|
7
|
+
from starlette.requests import Request
|
|
8
|
+
from starlette.responses import HTMLResponse
|
|
9
|
+
|
|
10
|
+
from contree_mcp.app import create_mcp_app
|
|
11
|
+
from contree_mcp.arguments import Parser, ServerMode
|
|
12
|
+
from contree_mcp.cache import Cache
|
|
13
|
+
from contree_mcp.client import ContreeClient
|
|
14
|
+
from contree_mcp.context import CLIENT, FILES_CACHE, ContextMiddleware
|
|
15
|
+
from contree_mcp.docs import generate_docs_html
|
|
16
|
+
from contree_mcp.file_cache import FileCache
|
|
17
|
+
from contree_mcp.resources.guide import SECTIONS
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def index_page(docs_html: str, _: Request) -> HTMLResponse:
|
|
23
|
+
return HTMLResponse(docs_html)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def amain(parser: Parser) -> None:
|
|
27
|
+
async with AsyncExitStack() as stack:
|
|
28
|
+
# Initialize all dependencies
|
|
29
|
+
files_cache = await stack.enter_async_context(FileCache(db_path=parser.cache.files.expanduser()))
|
|
30
|
+
general_cache = await stack.enter_async_context(
|
|
31
|
+
Cache(
|
|
32
|
+
db_path=parser.cache.general.expanduser(),
|
|
33
|
+
retention_days=parser.cache.prune_days,
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
client = await stack.enter_async_context(
|
|
37
|
+
ContreeClient(base_url=parser.url, token=parser.token, cache=general_cache)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
CLIENT.set(client)
|
|
41
|
+
FILES_CACHE.set(files_cache)
|
|
42
|
+
|
|
43
|
+
mcp = create_mcp_app()
|
|
44
|
+
|
|
45
|
+
log.debug("MCP app initialized: %s", CLIENT.get())
|
|
46
|
+
|
|
47
|
+
if parser.mode == ServerMode.HTTP:
|
|
48
|
+
log.info("Starting MCP server on http://%s:%d", parser.http.listen, parser.http.port)
|
|
49
|
+
|
|
50
|
+
# Generate docs HTML
|
|
51
|
+
tools = await mcp.list_tools()
|
|
52
|
+
templates = await mcp.list_resource_templates()
|
|
53
|
+
docs_html = generate_docs_html(
|
|
54
|
+
server_instructions=mcp.instructions or "",
|
|
55
|
+
tools=tools,
|
|
56
|
+
templates=templates,
|
|
57
|
+
guides=SECTIONS,
|
|
58
|
+
http_port=parser.http.port,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
app = mcp.streamable_http_app()
|
|
62
|
+
app.add_middleware(ContextMiddleware, ctx=contextvars.copy_context())
|
|
63
|
+
app.add_route("/", partial(index_page, docs_html), methods=["GET"])
|
|
64
|
+
|
|
65
|
+
config = uvicorn.Config(
|
|
66
|
+
app,
|
|
67
|
+
host=parser.http.listen,
|
|
68
|
+
port=parser.http.port,
|
|
69
|
+
log_level="info",
|
|
70
|
+
)
|
|
71
|
+
server = uvicorn.Server(config)
|
|
72
|
+
await server.serve()
|
|
73
|
+
elif parser.mode == ServerMode.STDIO:
|
|
74
|
+
log.info("Starting MCP server in stdio mode")
|
|
75
|
+
await mcp.run_stdio_async()
|
|
76
|
+
else:
|
|
77
|
+
raise ValueError(f"Unsupported server mode: {parser.mode}")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Contree MCP tools."""
|
|
2
|
+
|
|
3
|
+
from .cancel_operation import cancel_operation
|
|
4
|
+
from .download import download
|
|
5
|
+
from .get_guide import get_guide
|
|
6
|
+
from .get_image import get_image
|
|
7
|
+
from .get_operation import get_operation
|
|
8
|
+
from .import_image import import_image
|
|
9
|
+
from .list_files import list_files
|
|
10
|
+
from .list_images import list_images
|
|
11
|
+
from .list_operations import list_operations
|
|
12
|
+
from .read_file import read_file
|
|
13
|
+
from .registry_auth import registry_auth
|
|
14
|
+
from .registry_token_obtain import registry_token_obtain
|
|
15
|
+
from .rsync import rsync
|
|
16
|
+
from .run import run
|
|
17
|
+
from .set_tag import set_tag
|
|
18
|
+
from .upload import upload
|
|
19
|
+
from .wait_operations import wait_operations
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"cancel_operation",
|
|
23
|
+
"download",
|
|
24
|
+
"get_guide",
|
|
25
|
+
"get_image",
|
|
26
|
+
"get_operation",
|
|
27
|
+
"import_image",
|
|
28
|
+
"list_files",
|
|
29
|
+
"list_images",
|
|
30
|
+
"list_operations",
|
|
31
|
+
"read_file",
|
|
32
|
+
"registry_auth",
|
|
33
|
+
"registry_token_obtain",
|
|
34
|
+
"rsync",
|
|
35
|
+
"run",
|
|
36
|
+
"set_tag",
|
|
37
|
+
"upload",
|
|
38
|
+
"wait_operations",
|
|
39
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
from contree_mcp.context import CLIENT
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CancelOperationOutput(BaseModel):
|
|
7
|
+
cancelled: bool = Field(description="Whether cancellation succeeded")
|
|
8
|
+
operation_id: str = Field(description="UUID of the cancelled operation")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def cancel_operation(operation_id: str) -> CancelOperationOutput:
|
|
12
|
+
"""
|
|
13
|
+
Cancel a running operation. Free (no VM).
|
|
14
|
+
|
|
15
|
+
TL;DR:
|
|
16
|
+
- PURPOSE: Stop operations taking too long or no longer needed
|
|
17
|
+
- COST: Free (no VM) - saves resources by stopping unnecessary work
|
|
18
|
+
|
|
19
|
+
USAGE:
|
|
20
|
+
- Stop long-running commands that are taking too long
|
|
21
|
+
- Cancel image imports that are stuck or no longer needed
|
|
22
|
+
|
|
23
|
+
RETURNS: cancelled, operation_id
|
|
24
|
+
|
|
25
|
+
GUIDES:
|
|
26
|
+
- [USEFUL] contree://guide/async - Async execution and cancellation
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
client = CLIENT.get()
|
|
30
|
+
result_status = await client.cancel_operation(operation_id)
|
|
31
|
+
# Check if operation was cancelled (CANCELLED status)
|
|
32
|
+
cancelled = result_status == "CANCELLED"
|
|
33
|
+
return CancelOperationOutput(
|
|
34
|
+
cancelled=cancelled,
|
|
35
|
+
operation_id=operation_id,
|
|
36
|
+
)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
from collections.abc import AsyncIterable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from queue import Queue
|
|
8
|
+
from tempfile import mktemp
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ByteSize, Field
|
|
11
|
+
|
|
12
|
+
from contree_mcp.context import CLIENT
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DownloadSource(BaseModel):
|
|
16
|
+
image: str = Field(description="Image UUID")
|
|
17
|
+
path: str = Field(description="Path in container")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DownloadOutput(BaseModel):
|
|
21
|
+
success: bool = Field(description="Whether download succeeded")
|
|
22
|
+
source: DownloadSource = Field(description="Source image and path")
|
|
23
|
+
destination: str = Field(description="Local path where file was saved")
|
|
24
|
+
size: ByteSize = Field(description="File size in bytes")
|
|
25
|
+
executable: bool = Field(description="Whether file was made executable")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def async_file_writer(destination: Path, stream: AsyncIterable[bytes]) -> int:
|
|
29
|
+
queue: Queue[bytes | BaseException | None] = Queue(maxsize=16)
|
|
30
|
+
write_event = asyncio.Event()
|
|
31
|
+
loop = asyncio.get_running_loop()
|
|
32
|
+
|
|
33
|
+
def sync_writer(dest: Path) -> int:
|
|
34
|
+
total_bytes = 0
|
|
35
|
+
with dest.open("wb") as f:
|
|
36
|
+
while True:
|
|
37
|
+
match queue.get():
|
|
38
|
+
case None:
|
|
39
|
+
loop.call_soon_threadsafe(write_event.set)
|
|
40
|
+
return total_bytes
|
|
41
|
+
case BaseException():
|
|
42
|
+
# Error occurred, close file and remove temp
|
|
43
|
+
loop.call_soon_threadsafe(write_event.set)
|
|
44
|
+
f.close()
|
|
45
|
+
dest.unlink(missing_ok=True)
|
|
46
|
+
return -1
|
|
47
|
+
case bytes() as chunk:
|
|
48
|
+
loop.call_soon_threadsafe(write_event.set)
|
|
49
|
+
f.write(chunk)
|
|
50
|
+
queue.task_done()
|
|
51
|
+
total_bytes += len(chunk)
|
|
52
|
+
|
|
53
|
+
async def queue_waiter() -> None:
|
|
54
|
+
while queue.full():
|
|
55
|
+
write_event.clear()
|
|
56
|
+
# Circuit breaker to avoid deadlocks
|
|
57
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
58
|
+
await asyncio.wait_for(write_event.wait(), timeout=1)
|
|
59
|
+
|
|
60
|
+
tmp_path = Path(mktemp(dir=destination.parent, prefix=".download-", suffix=".tmp"))
|
|
61
|
+
task = asyncio.create_task(asyncio.to_thread(sync_writer, tmp_path))
|
|
62
|
+
error: BaseException | None = None
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
async for chunk in stream:
|
|
66
|
+
await queue_waiter()
|
|
67
|
+
queue.put_nowait(chunk)
|
|
68
|
+
except BaseException as e:
|
|
69
|
+
error = e
|
|
70
|
+
finally:
|
|
71
|
+
# Have to wait for queue to be drained before sending signal
|
|
72
|
+
await queue_waiter()
|
|
73
|
+
queue.put_nowait(error) # None for success, exception for error
|
|
74
|
+
|
|
75
|
+
await task
|
|
76
|
+
if error is not None:
|
|
77
|
+
raise error
|
|
78
|
+
|
|
79
|
+
await asyncio.to_thread(os.replace, tmp_path, destination)
|
|
80
|
+
return task.result()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def download(
|
|
84
|
+
image: str,
|
|
85
|
+
path: str,
|
|
86
|
+
destination: str,
|
|
87
|
+
executable: bool = False,
|
|
88
|
+
) -> DownloadOutput:
|
|
89
|
+
"""
|
|
90
|
+
Download file from container image to local filesystem. Free (no VM).
|
|
91
|
+
|
|
92
|
+
TL;DR:
|
|
93
|
+
- PURPOSE: Extract files from container images to local filesystem
|
|
94
|
+
- EXECUTABLE: Set executable=true for binaries to run locally
|
|
95
|
+
- COST: Free (no VM)
|
|
96
|
+
|
|
97
|
+
USAGE:
|
|
98
|
+
- destination: Absolute path for downloaded file (~ expansion supported)
|
|
99
|
+
- Extract compiled binaries or build artifacts
|
|
100
|
+
- Save configuration files for local editing
|
|
101
|
+
- Retrieve logs or output files from completed runs
|
|
102
|
+
|
|
103
|
+
RETURNS: success, destination, size, size_human
|
|
104
|
+
|
|
105
|
+
GUIDES:
|
|
106
|
+
- [USEFUL] contree://guide/reference - Tool reference and resources
|
|
107
|
+
"""
|
|
108
|
+
client = CLIENT.get()
|
|
109
|
+
image_uuid = await client.resolve_image(image)
|
|
110
|
+
dest_path = Path(os.path.expanduser(destination))
|
|
111
|
+
if not dest_path.is_absolute():
|
|
112
|
+
raise ValueError(f"destination must be an absolute path, got: {destination}")
|
|
113
|
+
|
|
114
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
|
|
116
|
+
async with client.stream_file(image_uuid, path) as chunks:
|
|
117
|
+
file_size = await async_file_writer(dest_path, chunks)
|
|
118
|
+
|
|
119
|
+
if executable and platform.system() != "Windows":
|
|
120
|
+
dest_path.chmod(0o755)
|
|
121
|
+
|
|
122
|
+
return DownloadOutput(
|
|
123
|
+
success=True,
|
|
124
|
+
source=DownloadSource(image=image_uuid, path=path),
|
|
125
|
+
destination=str(dest_path),
|
|
126
|
+
size=ByteSize(file_size),
|
|
127
|
+
executable=executable,
|
|
128
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
from contree_mcp.resources.guide import SECTIONS
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GetGuideOutput(BaseModel):
|
|
7
|
+
section: str
|
|
8
|
+
content: str
|
|
9
|
+
available_sections: list[str]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def get_guide(section: str) -> GetGuideOutput:
|
|
13
|
+
"""
|
|
14
|
+
Get agent guide sections for Contree best practices. Free (no VM).
|
|
15
|
+
|
|
16
|
+
TL;DR:
|
|
17
|
+
- PURPOSE: Access documentation and best practices for using Contree
|
|
18
|
+
- SECTIONS: workflow, reference, quickstart, state, async, tagging, errors
|
|
19
|
+
- COST: Free (no VM)
|
|
20
|
+
|
|
21
|
+
USAGE:
|
|
22
|
+
- Get workflow patterns: get_guide(section="workflow")
|
|
23
|
+
- Get tool reference: get_guide(section="reference")
|
|
24
|
+
- Get quick start examples: get_guide(section="quickstart")
|
|
25
|
+
- Get state management guide: get_guide(section="state")
|
|
26
|
+
- Get async execution guide: get_guide(section="async")
|
|
27
|
+
- Get tagging convention: get_guide(section="tagging")
|
|
28
|
+
- Get error handling guide: get_guide(section="errors")
|
|
29
|
+
|
|
30
|
+
RETURNS: section, content, available_sections
|
|
31
|
+
|
|
32
|
+
AVAILABLE SECTIONS:
|
|
33
|
+
- workflow: Complete workflow patterns with decision tree
|
|
34
|
+
- reference: Tool reference with parameters and data flow
|
|
35
|
+
- quickstart: Quick examples for common operations
|
|
36
|
+
- state: State management and rollback patterns
|
|
37
|
+
- async: Parallel execution and wait_operations
|
|
38
|
+
- tagging: Tagging convention for prepared environments
|
|
39
|
+
- errors: Error handling and debugging guide
|
|
40
|
+
|
|
41
|
+
RESOURCE ALTERNATIVE:
|
|
42
|
+
- contree://guide/{section} - Same content as MCP resource (if your agent supports resources)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
available = sorted(SECTIONS.keys())
|
|
46
|
+
|
|
47
|
+
if section not in SECTIONS:
|
|
48
|
+
raise ValueError(f"Unknown guide section '{section}'. Available sections: {', '.join(available)}")
|
|
49
|
+
|
|
50
|
+
return GetGuideOutput(
|
|
51
|
+
section=section,
|
|
52
|
+
content=SECTIONS[section],
|
|
53
|
+
available_sections=available,
|
|
54
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from contree_mcp.backend_types import Image
|
|
2
|
+
from contree_mcp.context import CLIENT
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
async def get_image(image: str) -> Image:
|
|
6
|
+
"""
|
|
7
|
+
Get image details by UUID or tag. Free (no VM).
|
|
8
|
+
|
|
9
|
+
TL;DR:
|
|
10
|
+
- PURPOSE: Verify image exists or resolve tag to UUID
|
|
11
|
+
- FORMAT: Use "tag:python:3.11" to look up by tag
|
|
12
|
+
- COST: Free (no VM)
|
|
13
|
+
|
|
14
|
+
USAGE:
|
|
15
|
+
- Look up image metadata (UUID, tag, creation time)
|
|
16
|
+
- Verify image exists before running commands
|
|
17
|
+
- Resolve tag to underlying UUID
|
|
18
|
+
- Prefer verifying an existing image before using import_image
|
|
19
|
+
|
|
20
|
+
RETURNS: uuid, tag, created_at
|
|
21
|
+
|
|
22
|
+
GUIDES:
|
|
23
|
+
- [USEFUL] contree://guide/quickstart - UUIDs vs tags guidance
|
|
24
|
+
"""
|
|
25
|
+
client = CLIENT.get()
|
|
26
|
+
if image.startswith("tag:"):
|
|
27
|
+
img = await client.get_image_by_tag(image[4:])
|
|
28
|
+
else:
|
|
29
|
+
img = await client.get_image(image)
|
|
30
|
+
return Image(uuid=img.uuid, tag=img.tag, created_at=img.created_at)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from contree_mcp.backend_types import OperationResponse
|
|
2
|
+
from contree_mcp.context import CLIENT
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
async def get_operation(operation_id: str) -> OperationResponse:
|
|
6
|
+
"""
|
|
7
|
+
Get status and result of an operation. Free (no VM).
|
|
8
|
+
|
|
9
|
+
TL;DR:
|
|
10
|
+
- PURPOSE: Poll async operations launched with wait=false
|
|
11
|
+
- PREFER: Use wait_operations for multiple operations
|
|
12
|
+
- COST: Free (no VM)
|
|
13
|
+
|
|
14
|
+
USAGE:
|
|
15
|
+
- Check status of async operations started with wait=false
|
|
16
|
+
- Retrieve stdout/stderr from completed command executions
|
|
17
|
+
- Get result_image UUID from non-disposable command runs
|
|
18
|
+
|
|
19
|
+
RETURNS: state, stdout, stderr, exit_code, result_image
|
|
20
|
+
|
|
21
|
+
GUIDES:
|
|
22
|
+
- [ESSENTIAL] contree://guide/async - Async execution and polling
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
client = CLIENT.get()
|
|
26
|
+
return await client.get_operation(operation_id)
|