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.
@@ -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,4 @@
1
+ from objectstore_mcp.agent_server import agent_server
2
+
3
+ if __name__ == "__main__":
4
+ agent_server()
@@ -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}"