objectstore-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.
- objectstore_mcp/__init__.py +63 -0
- objectstore_mcp/__main__.py +4 -0
- objectstore_mcp/agent_server.py +73 -0
- objectstore_mcp/api/__init__.py +84 -0
- objectstore_mcp/api/api_client_azure_blob.py +308 -0
- objectstore_mcp/api/api_client_base.py +234 -0
- objectstore_mcp/api/api_client_filesystem.py +285 -0
- objectstore_mcp/api/api_client_gcs.py +268 -0
- objectstore_mcp/api/api_client_s3.py +291 -0
- objectstore_mcp/api_client.py +37 -0
- objectstore_mcp/auth.py +64 -0
- objectstore_mcp/config.py +170 -0
- objectstore_mcp/main_agent.json +14 -0
- objectstore_mcp/mcp/__init__.py +5 -0
- objectstore_mcp/mcp/mcp_objectstore.py +471 -0
- objectstore_mcp/mcp_config.json +1 -0
- objectstore_mcp/mcp_server.py +61 -0
- objectstore_mcp/objectstore_input_models.py +64 -0
- objectstore_mcp/objectstore_response_models.py +52 -0
- objectstore_mcp-0.1.0.dist-info/METADATA +234 -0
- objectstore_mcp-0.1.0.dist-info/RECORD +25 -0
- objectstore_mcp-0.1.0.dist-info/WHEEL +5 -0
- objectstore_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- objectstore_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- objectstore_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""objectstore-mcp — multi-backend object-storage API + MCP Server + A2A Agent.
|
|
2
|
+
|
|
3
|
+
One MCP tool surface (objects/buckets/transfer) over S3 and S3-compatible
|
|
4
|
+
stores (MinIO, Cloudflare R2), Google Cloud Storage, Azure Blob Storage, and
|
|
5
|
+
a zero-infra local-filesystem backend (CONCEPT:OBJ-1.0).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib
|
|
9
|
+
import inspect
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
__all__: list[str] = []
|
|
14
|
+
|
|
15
|
+
CORE_MODULES = ["objectstore_mcp.api_client"]
|
|
16
|
+
OPTIONAL_MODULES = {
|
|
17
|
+
"objectstore_mcp.agent_server": "agent",
|
|
18
|
+
"objectstore_mcp.mcp_server": "mcp",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _expose_members(module):
|
|
23
|
+
for name, obj in inspect.getmembers(module):
|
|
24
|
+
if (inspect.isclass(obj) or inspect.isfunction(obj)) and not name.startswith(
|
|
25
|
+
"_"
|
|
26
|
+
):
|
|
27
|
+
globals()[name] = obj
|
|
28
|
+
if name not in __all__:
|
|
29
|
+
__all__.append(name)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
for module_name in CORE_MODULES:
|
|
33
|
+
module = importlib.import_module(module_name)
|
|
34
|
+
_expose_members(module)
|
|
35
|
+
|
|
36
|
+
_loaded_optional_modules: dict = {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _import_module_safely(module_name: str):
|
|
40
|
+
try:
|
|
41
|
+
return importlib.import_module(module_name)
|
|
42
|
+
except ImportError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def __getattr__(name: str) -> Any:
|
|
47
|
+
if name == "_MCP_AVAILABLE":
|
|
48
|
+
mcp_key = next((k for k in OPTIONAL_MODULES if "mcp_server" in k), None)
|
|
49
|
+
return _import_module_safely(mcp_key) is not None if mcp_key else False
|
|
50
|
+
if name == "_AGENT_AVAILABLE":
|
|
51
|
+
agent_key = next((k for k in OPTIONAL_MODULES if "agent_server" in k), None)
|
|
52
|
+
return _import_module_safely(agent_key) is not None if agent_key else False
|
|
53
|
+
|
|
54
|
+
for module_name in OPTIONAL_MODULES:
|
|
55
|
+
if module_name not in _loaded_optional_modules:
|
|
56
|
+
module = _import_module_safely(module_name)
|
|
57
|
+
_loaded_optional_modules[module_name] = module
|
|
58
|
+
if module is not None:
|
|
59
|
+
_expose_members(module)
|
|
60
|
+
module = _loaded_optional_modules[module_name]
|
|
61
|
+
if module is not None and hasattr(module, name):
|
|
62
|
+
return getattr(module, name)
|
|
63
|
+
raise AttributeError(f"module 'objectstore_mcp' has no attribute {name!r}")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
logging.basicConfig(
|
|
10
|
+
level=logging.INFO,
|
|
11
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
12
|
+
handlers=[logging.StreamHandler()],
|
|
13
|
+
)
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def agent_server():
|
|
18
|
+
from agent_utilities import (
|
|
19
|
+
build_system_prompt_from_workspace,
|
|
20
|
+
create_agent_parser,
|
|
21
|
+
create_agent_server,
|
|
22
|
+
initialize_workspace,
|
|
23
|
+
load_identity,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
warnings.filterwarnings("ignore", message=".*urllib3.*or chardet.*")
|
|
27
|
+
warnings.filterwarnings("ignore", category=DeprecationWarning, module="fastmcp")
|
|
28
|
+
|
|
29
|
+
initialize_workspace()
|
|
30
|
+
meta = load_identity()
|
|
31
|
+
agent_name = os.getenv("DEFAULT_AGENT_NAME", meta.get("name", "Objectstore Mcp"))
|
|
32
|
+
|
|
33
|
+
print(f"{agent_name} v{__version__}", file=sys.stderr)
|
|
34
|
+
parser = create_agent_parser()
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
|
|
37
|
+
if args.debug:
|
|
38
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
|
39
|
+
logger.debug("Debug mode enabled")
|
|
40
|
+
|
|
41
|
+
create_agent_server(
|
|
42
|
+
mcp_url=args.mcp_url,
|
|
43
|
+
mcp_config=args.mcp_config or "mcp_config.json",
|
|
44
|
+
host=args.host,
|
|
45
|
+
port=args.port,
|
|
46
|
+
provider=args.provider,
|
|
47
|
+
model_id=args.model_id,
|
|
48
|
+
router_model=args.model_id,
|
|
49
|
+
agent_model=args.model_id,
|
|
50
|
+
base_url=args.base_url,
|
|
51
|
+
api_key=args.api_key,
|
|
52
|
+
agent_description=os.getenv(
|
|
53
|
+
"AGENT_DESCRIPTION",
|
|
54
|
+
meta.get("description", "Objectstore Mcp API + MCP Server + A2A Server"),
|
|
55
|
+
),
|
|
56
|
+
system_prompt=os.getenv(
|
|
57
|
+
"AGENT_SYSTEM_PROMPT",
|
|
58
|
+
meta.get("content") or build_system_prompt_from_workspace(),
|
|
59
|
+
),
|
|
60
|
+
custom_skills_directory=args.custom_skills_directory,
|
|
61
|
+
enable_web_ui=args.web,
|
|
62
|
+
enable_otel=args.otel,
|
|
63
|
+
otel_endpoint=args.otel_endpoint,
|
|
64
|
+
otel_headers=args.otel_headers,
|
|
65
|
+
otel_public_key=args.otel_public_key,
|
|
66
|
+
otel_secret_key=args.otel_secret_key,
|
|
67
|
+
otel_protocol=args.otel_protocol,
|
|
68
|
+
debug=args.debug,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
agent_server()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Backend factory: map a store's ``backend`` string to an implementation.
|
|
2
|
+
|
|
3
|
+
Imports are lazy so the core install carries zero cloud dependencies
|
|
4
|
+
(CONCEPT:OBJ-1.5); a missing SDK surfaces as :class:`MissingDependencyError`
|
|
5
|
+
naming the pip extra to install.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from objectstore_mcp.api.api_client_base import (
|
|
11
|
+
AlreadyExistsError,
|
|
12
|
+
BucketInfo,
|
|
13
|
+
BucketNotEmptyError,
|
|
14
|
+
InvalidNameError,
|
|
15
|
+
Metadata,
|
|
16
|
+
MissingDependencyError,
|
|
17
|
+
NotFoundError,
|
|
18
|
+
ObjectInfo,
|
|
19
|
+
ObjectPage,
|
|
20
|
+
ObjectStoreBackend,
|
|
21
|
+
ObjectStoreError,
|
|
22
|
+
UnsupportedOperationError,
|
|
23
|
+
)
|
|
24
|
+
from objectstore_mcp.config import DEFAULT_FS_ROOT, StoreConfig
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AlreadyExistsError",
|
|
28
|
+
"BucketInfo",
|
|
29
|
+
"BucketNotEmptyError",
|
|
30
|
+
"InvalidNameError",
|
|
31
|
+
"Metadata",
|
|
32
|
+
"MissingDependencyError",
|
|
33
|
+
"NotFoundError",
|
|
34
|
+
"ObjectInfo",
|
|
35
|
+
"ObjectPage",
|
|
36
|
+
"ObjectStoreBackend",
|
|
37
|
+
"ObjectStoreError",
|
|
38
|
+
"UnsupportedOperationError",
|
|
39
|
+
"create_backend",
|
|
40
|
+
"BACKEND_ALIASES",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
BACKEND_ALIASES = {
|
|
44
|
+
"filesystem": "filesystem",
|
|
45
|
+
"fs": "filesystem",
|
|
46
|
+
"local": "filesystem",
|
|
47
|
+
"s3": "s3",
|
|
48
|
+
"minio": "s3",
|
|
49
|
+
"r2": "s3",
|
|
50
|
+
"gcs": "gcs",
|
|
51
|
+
"gcp": "gcs",
|
|
52
|
+
"google": "gcs",
|
|
53
|
+
"azure": "azure",
|
|
54
|
+
"azure_blob": "azure",
|
|
55
|
+
"abs": "azure",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_backend(store: StoreConfig) -> ObjectStoreBackend:
|
|
60
|
+
"""Instantiate the backend a :class:`StoreConfig` names."""
|
|
61
|
+
kind = BACKEND_ALIASES.get(store.backend)
|
|
62
|
+
if kind == "filesystem":
|
|
63
|
+
from objectstore_mcp.api.api_client_filesystem import FilesystemBackend
|
|
64
|
+
|
|
65
|
+
root = store.root or os.getenv("OBJECTSTORE_FS_ROOT") or DEFAULT_FS_ROOT
|
|
66
|
+
return FilesystemBackend(root=root)
|
|
67
|
+
if kind == "s3":
|
|
68
|
+
from objectstore_mcp.api.api_client_s3 import S3Backend
|
|
69
|
+
|
|
70
|
+
return S3Backend(
|
|
71
|
+
endpoint=store.endpoint, profile=store.profile, region=store.region
|
|
72
|
+
)
|
|
73
|
+
if kind == "gcs":
|
|
74
|
+
from objectstore_mcp.api.api_client_gcs import GCSBackend
|
|
75
|
+
|
|
76
|
+
return GCSBackend(project=store.project)
|
|
77
|
+
if kind == "azure":
|
|
78
|
+
from objectstore_mcp.api.api_client_azure_blob import AzureBlobBackend
|
|
79
|
+
|
|
80
|
+
return AzureBlobBackend(connection_string=store.connection_string)
|
|
81
|
+
raise ObjectStoreError(
|
|
82
|
+
f"Unknown backend {store.backend!r} for store {store.name!r}. "
|
|
83
|
+
f"Valid backends: {sorted(set(BACKEND_ALIASES))}."
|
|
84
|
+
)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Azure Blob Storage backend (CONCEPT:OBJ-1.5).
|
|
2
|
+
|
|
3
|
+
Containers map to buckets. Credentials come from
|
|
4
|
+
``AZURE_STORAGE_CONNECTION_STRING`` (or an explicit ``connection_string``
|
|
5
|
+
store option). Presigned URLs are SAS URLs and require an account key in the
|
|
6
|
+
connection string.
|
|
7
|
+
|
|
8
|
+
Requires the ``azure`` extra: ``pip install objectstore-mcp[azure]``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from objectstore_mcp.api.api_client_base import (
|
|
16
|
+
AlreadyExistsError,
|
|
17
|
+
BucketInfo,
|
|
18
|
+
BucketNotEmptyError,
|
|
19
|
+
Metadata,
|
|
20
|
+
MissingDependencyError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
ObjectInfo,
|
|
23
|
+
ObjectPage,
|
|
24
|
+
ObjectStoreError,
|
|
25
|
+
validate_bucket_name,
|
|
26
|
+
validate_key,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_client(connection_string: str | None) -> Any:
|
|
31
|
+
try:
|
|
32
|
+
from azure.storage.blob import BlobServiceClient
|
|
33
|
+
except ImportError as exc: # pragma: no cover - exercised via mocked import
|
|
34
|
+
raise MissingDependencyError(
|
|
35
|
+
"The Azure backend requires azure-storage-blob. Install it with "
|
|
36
|
+
"`pip install objectstore-mcp[azure]`."
|
|
37
|
+
) from exc
|
|
38
|
+
conn = connection_string or os.getenv("AZURE_STORAGE_CONNECTION_STRING")
|
|
39
|
+
if not conn:
|
|
40
|
+
raise ObjectStoreError(
|
|
41
|
+
"Azure backend needs AZURE_STORAGE_CONNECTION_STRING (or a "
|
|
42
|
+
"'connection_string' store option)."
|
|
43
|
+
)
|
|
44
|
+
return BlobServiceClient.from_connection_string(conn)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_not_found(exc: Exception) -> bool:
|
|
48
|
+
return (
|
|
49
|
+
getattr(exc, "status_code", None) == 404
|
|
50
|
+
or exc.__class__.__name__ == "ResourceNotFoundError"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_conflict(exc: Exception) -> bool:
|
|
55
|
+
return (
|
|
56
|
+
getattr(exc, "status_code", None) == 409
|
|
57
|
+
or exc.__class__.__name__ == "ResourceExistsError"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AzureBlobBackend:
|
|
62
|
+
"""Azure Blob Storage object-store backend (containers as buckets)."""
|
|
63
|
+
|
|
64
|
+
backend_type = "azure"
|
|
65
|
+
|
|
66
|
+
def __init__(self, connection_string: str | None = None, client: Any | None = None):
|
|
67
|
+
"""``client`` injects a pre-built BlobServiceClient (used by tests)."""
|
|
68
|
+
self.client = client if client is not None else _build_client(connection_string)
|
|
69
|
+
|
|
70
|
+
def capabilities(self) -> dict[str, bool]:
|
|
71
|
+
return {
|
|
72
|
+
"presigned_urls": True,
|
|
73
|
+
"object_metadata": True,
|
|
74
|
+
"bucket_location": False,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def _translate(self, exc: Exception, context: str) -> ObjectStoreError:
|
|
78
|
+
if _is_not_found(exc):
|
|
79
|
+
return NotFoundError(f"{context} not found.")
|
|
80
|
+
if _is_conflict(exc):
|
|
81
|
+
return AlreadyExistsError(f"{context} already exists.")
|
|
82
|
+
return ObjectStoreError(f"{context}: {exc}")
|
|
83
|
+
|
|
84
|
+
# -- buckets -------------------------------------------------------------
|
|
85
|
+
def list_buckets(self) -> list[BucketInfo]:
|
|
86
|
+
try:
|
|
87
|
+
containers = list(self.client.list_containers())
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
raise self._translate(exc, "Container listing") from exc
|
|
90
|
+
results = []
|
|
91
|
+
for container in containers:
|
|
92
|
+
modified = getattr(container, "last_modified", None)
|
|
93
|
+
results.append(
|
|
94
|
+
BucketInfo(
|
|
95
|
+
name=container.name,
|
|
96
|
+
created=modified.isoformat() if modified else None,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
return results
|
|
100
|
+
|
|
101
|
+
def create_bucket(self, bucket: str, location: str | None = None) -> BucketInfo:
|
|
102
|
+
validate_bucket_name(bucket)
|
|
103
|
+
try:
|
|
104
|
+
self.client.create_container(bucket)
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
raise self._translate(exc, f"Container {bucket!r}") from exc
|
|
107
|
+
return BucketInfo(name=bucket)
|
|
108
|
+
|
|
109
|
+
def delete_bucket(self, bucket: str) -> None:
|
|
110
|
+
container = self.client.get_container_client(bucket)
|
|
111
|
+
try:
|
|
112
|
+
# Azure deletes non-empty containers; enforce empty-only parity.
|
|
113
|
+
if next(iter(container.list_blobs()), None) is not None:
|
|
114
|
+
raise BucketNotEmptyError(f"Container {bucket!r} is not empty.")
|
|
115
|
+
container.delete_container()
|
|
116
|
+
except BucketNotEmptyError:
|
|
117
|
+
raise
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
raise self._translate(exc, f"Container {bucket!r}") from exc
|
|
120
|
+
|
|
121
|
+
def bucket_exists(self, bucket: str) -> bool:
|
|
122
|
+
try:
|
|
123
|
+
return bool(self.client.get_container_client(bucket).exists())
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
raise self._translate(exc, f"Container {bucket!r}") from exc
|
|
126
|
+
|
|
127
|
+
def bucket_info(self, bucket: str) -> BucketInfo:
|
|
128
|
+
container = self.client.get_container_client(bucket)
|
|
129
|
+
try:
|
|
130
|
+
props = container.get_container_properties()
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
raise self._translate(exc, f"Container {bucket!r}") from exc
|
|
133
|
+
modified = getattr(props, "last_modified", None)
|
|
134
|
+
return BucketInfo(
|
|
135
|
+
name=bucket, created=modified.isoformat() if modified else None
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# -- objects ---------------------------------------------------------------
|
|
139
|
+
def list_objects(
|
|
140
|
+
self,
|
|
141
|
+
bucket: str,
|
|
142
|
+
prefix: str = "",
|
|
143
|
+
delimiter: str | None = None,
|
|
144
|
+
max_keys: int = 1000,
|
|
145
|
+
continuation_token: str | None = None,
|
|
146
|
+
) -> ObjectPage:
|
|
147
|
+
container = self.client.get_container_client(bucket)
|
|
148
|
+
try:
|
|
149
|
+
if delimiter:
|
|
150
|
+
iterator = container.walk_blobs(
|
|
151
|
+
name_starts_with=prefix or None,
|
|
152
|
+
delimiter=delimiter,
|
|
153
|
+
results_per_page=max_keys,
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
iterator = container.list_blobs(
|
|
157
|
+
name_starts_with=prefix or None, results_per_page=max_keys
|
|
158
|
+
)
|
|
159
|
+
pager = iterator.by_page(continuation_token=continuation_token)
|
|
160
|
+
entries = list(next(pager, []))
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
raise self._translate(exc, f"Container {bucket!r}") from exc
|
|
163
|
+
next_token = getattr(pager, "continuation_token", None) or None
|
|
164
|
+
page = ObjectPage(next_token=next_token, truncated=bool(next_token))
|
|
165
|
+
for entry in entries:
|
|
166
|
+
# walk_blobs yields BlobPrefix markers for folded prefixes.
|
|
167
|
+
if entry.__class__.__name__ == "BlobPrefix" or not hasattr(entry, "size"):
|
|
168
|
+
page.prefixes.append(entry.name)
|
|
169
|
+
else:
|
|
170
|
+
page.objects.append(self._blob_info(entry))
|
|
171
|
+
page.prefixes.sort()
|
|
172
|
+
return page
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _blob_info(blob: Any) -> ObjectInfo:
|
|
176
|
+
modified = getattr(blob, "last_modified", None)
|
|
177
|
+
settings = getattr(blob, "content_settings", None)
|
|
178
|
+
return ObjectInfo(
|
|
179
|
+
key=blob.name,
|
|
180
|
+
size=getattr(blob, "size", 0) or 0,
|
|
181
|
+
etag=(getattr(blob, "etag", None) or "").strip('"') or None,
|
|
182
|
+
last_modified=modified.isoformat() if modified else None,
|
|
183
|
+
content_type=getattr(settings, "content_type", None),
|
|
184
|
+
metadata=dict(getattr(blob, "metadata", None) or {}),
|
|
185
|
+
storage_class=getattr(blob, "blob_tier", None),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _blob_client(self, bucket: str, key: str) -> Any:
|
|
189
|
+
return self.client.get_blob_client(container=bucket, blob=key)
|
|
190
|
+
|
|
191
|
+
def _get_properties(self, bucket: str, key: str) -> Any:
|
|
192
|
+
try:
|
|
193
|
+
return self._blob_client(bucket, key).get_blob_properties()
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
raise self._translate(exc, f"Blob {bucket!r}/{key!r}") from exc
|
|
196
|
+
|
|
197
|
+
def head_object(self, bucket: str, key: str) -> ObjectInfo:
|
|
198
|
+
props = self._get_properties(bucket, key)
|
|
199
|
+
info = self._blob_info(props)
|
|
200
|
+
info.key = key
|
|
201
|
+
return info
|
|
202
|
+
|
|
203
|
+
def get_object(self, bucket: str, key: str, max_bytes: int | None = None) -> bytes:
|
|
204
|
+
info = self.head_object(bucket, key)
|
|
205
|
+
if max_bytes is not None and info.size > max_bytes:
|
|
206
|
+
raise ObjectStoreError(
|
|
207
|
+
f"Blob {bucket!r}/{key!r} is {info.size} bytes, over the "
|
|
208
|
+
f"{max_bytes}-byte read cap."
|
|
209
|
+
)
|
|
210
|
+
try:
|
|
211
|
+
return self._blob_client(bucket, key).download_blob().readall()
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
raise self._translate(exc, f"Blob {bucket!r}/{key!r}") from exc
|
|
214
|
+
|
|
215
|
+
def put_object(
|
|
216
|
+
self,
|
|
217
|
+
bucket: str,
|
|
218
|
+
key: str,
|
|
219
|
+
data: bytes,
|
|
220
|
+
content_type: str | None = None,
|
|
221
|
+
metadata: Metadata | None = None,
|
|
222
|
+
) -> ObjectInfo:
|
|
223
|
+
validate_key(key)
|
|
224
|
+
kwargs: dict[str, Any] = {"overwrite": True}
|
|
225
|
+
if metadata:
|
|
226
|
+
kwargs["metadata"] = dict(metadata)
|
|
227
|
+
if content_type:
|
|
228
|
+
try:
|
|
229
|
+
from azure.storage.blob import ContentSettings
|
|
230
|
+
|
|
231
|
+
kwargs["content_settings"] = ContentSettings(content_type=content_type)
|
|
232
|
+
except ImportError:
|
|
233
|
+
# Injected-client mode (tests) without the SDK installed:
|
|
234
|
+
# pass the raw content type through.
|
|
235
|
+
kwargs["content_type"] = content_type
|
|
236
|
+
try:
|
|
237
|
+
self._blob_client(bucket, key).upload_blob(data, **kwargs)
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
raise self._translate(exc, f"Blob {bucket!r}/{key!r}") from exc
|
|
240
|
+
return ObjectInfo(
|
|
241
|
+
key=key,
|
|
242
|
+
size=len(data),
|
|
243
|
+
content_type=content_type,
|
|
244
|
+
metadata=dict(metadata or {}),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def copy_object(
|
|
248
|
+
self, src_bucket: str, src_key: str, dst_bucket: str, dst_key: str
|
|
249
|
+
) -> ObjectInfo:
|
|
250
|
+
validate_key(dst_key)
|
|
251
|
+
source_url = self._blob_client(src_bucket, src_key).url
|
|
252
|
+
try:
|
|
253
|
+
self._blob_client(dst_bucket, dst_key).start_copy_from_url(source_url)
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
raise self._translate(
|
|
256
|
+
exc, f"Copy {src_bucket!r}/{src_key!r} -> {dst_bucket!r}/{dst_key!r}"
|
|
257
|
+
) from exc
|
|
258
|
+
return self.head_object(dst_bucket, dst_key)
|
|
259
|
+
|
|
260
|
+
def delete_object(self, bucket: str, key: str) -> None:
|
|
261
|
+
try:
|
|
262
|
+
self._blob_client(bucket, key).delete_blob()
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
raise self._translate(exc, f"Blob {bucket!r}/{key!r}") from exc
|
|
265
|
+
|
|
266
|
+
def get_object_metadata(self, bucket: str, key: str) -> dict[str, str]:
|
|
267
|
+
return self.head_object(bucket, key).metadata
|
|
268
|
+
|
|
269
|
+
def set_object_metadata(
|
|
270
|
+
self, bucket: str, key: str, metadata: Metadata
|
|
271
|
+
) -> ObjectInfo:
|
|
272
|
+
try:
|
|
273
|
+
self._blob_client(bucket, key).set_blob_metadata(dict(metadata or {}))
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
raise self._translate(exc, f"Blob {bucket!r}/{key!r}") from exc
|
|
276
|
+
return self.head_object(bucket, key)
|
|
277
|
+
|
|
278
|
+
def presigned_url(
|
|
279
|
+
self, bucket: str, key: str, method: str = "GET", expires_in: int = 3600
|
|
280
|
+
) -> str:
|
|
281
|
+
if method.upper() not in ("GET", "PUT"):
|
|
282
|
+
raise ObjectStoreError(
|
|
283
|
+
f"Presign method must be GET or PUT (got {method!r})."
|
|
284
|
+
)
|
|
285
|
+
try:
|
|
286
|
+
from azure.storage.blob import BlobSasPermissions, generate_blob_sas
|
|
287
|
+
except ImportError as exc:
|
|
288
|
+
raise MissingDependencyError(
|
|
289
|
+
"SAS generation requires azure-storage-blob. Install it with "
|
|
290
|
+
"`pip install objectstore-mcp[azure]`."
|
|
291
|
+
) from exc
|
|
292
|
+
account_key = getattr(self.client.credential, "account_key", None)
|
|
293
|
+
if not account_key:
|
|
294
|
+
raise ObjectStoreError(
|
|
295
|
+
"SAS URLs require an account key in the connection string."
|
|
296
|
+
)
|
|
297
|
+
permission = BlobSasPermissions(
|
|
298
|
+
read=method.upper() == "GET", write=method.upper() == "PUT"
|
|
299
|
+
)
|
|
300
|
+
sas = generate_blob_sas(
|
|
301
|
+
account_name=self.client.account_name,
|
|
302
|
+
container_name=bucket,
|
|
303
|
+
blob_name=key,
|
|
304
|
+
account_key=account_key,
|
|
305
|
+
permission=permission,
|
|
306
|
+
expiry=datetime.now(timezone.utc) + timedelta(seconds=expires_in),
|
|
307
|
+
)
|
|
308
|
+
return f"{self._blob_client(bucket, key).url}?{sas}"
|