hanzo-tools-s3 0.1.0__tar.gz

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,76 @@
1
+ # IDE and editor
2
+ .vscode
3
+ .idea
4
+
5
+ # Python
6
+ *.egg-info
7
+ __pycache__
8
+ .mypy_cache
9
+ .pytest_cache
10
+ *.pyc
11
+ .venv
12
+
13
+ # Build
14
+ build/
15
+ dist
16
+ _dev
17
+
18
+ # Environment
19
+ .env
20
+ .envrc
21
+
22
+ # Logs
23
+ .prism.log
24
+ codegen.log
25
+ *.log
26
+
27
+ # Package manager
28
+ Brewfile.lock.json
29
+
30
+ # Agent config files (symlinked from user home)
31
+ LLM.md
32
+ AGENTS.md
33
+ CLAUDE.md
34
+ GEMINI.md
35
+ GROK.md
36
+ QWEN.md
37
+
38
+ # Local databases and state
39
+ .hanzo/
40
+ .grok/
41
+
42
+ # Documentation build
43
+ docs/.next/
44
+ docs/out/
45
+ docs/node_modules/
46
+
47
+ # Training data and scripts (DO NOT COMMIT)
48
+ training_dataset.jsonl
49
+ scripts/full_extractor.py
50
+ scripts/mega_extractor.py
51
+ scripts/mega_full_extractor.py
52
+ scripts/streaming_extractor.py
53
+ scripts/supplement_extractor.py
54
+
55
+ # Test files at root (experimental)
56
+ test_post_quantum_*.py
57
+
58
+ # Analysis documents (internal)
59
+ HANZO_INNOVATION_OPPORTUNITIES.md
60
+ POST_QUANTUM_CRYPTOGRAPHY_IMPLEMENTATION.md
61
+
62
+ # Experimental cryptography (WIP)
63
+ pkg/hanzo/src/hanzo/cryptography/
64
+ site/
65
+
66
+ # hygiene (untrack node_modules, block common build output)
67
+ node_modules/
68
+ **/node_modules/
69
+ .pnpm-store/
70
+ dist/
71
+ .next/
72
+ coverage/
73
+ playwright-report/
74
+ test-results/
75
+ tmp/
76
+ .DS_Store
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: hanzo-tools-s3
3
+ Version: 0.1.0
4
+ Summary: Hanzo S3 MCP tool — buckets and objects on S3-compatible storage
5
+ Author-email: Hanzo AI <dev@hanzo.ai>
6
+ License: MIT
7
+ Keywords: hanzo,mcp,object-storage,s3,seaweedfs,tools
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Requires-Python: >=3.12
14
+ Requires-Dist: hanzo-s3>=1.0.0
15
+ Requires-Dist: hanzo-tools-core>=0.1.0
16
+ Description-Content-Type: text/markdown
17
+
18
+ # hanzo-tools-s3
19
+
20
+ MCP tool for Hanzo S3 — S3-compatible object storage (s3.hanzo.ai).
21
+
22
+ Exposes a single `s3` tool with bucket and object actions: `buckets`,
23
+ `make_bucket`, `remove_bucket`, `objects`, `stat`, `remove_object`, `presign`.
24
+
25
+ Credentials come from the environment — inject them from KMS, never plaintext:
26
+
27
+ ```bash
28
+ eval "$(hanzo kms inject <project> <env>)" # exports HANZO_S3_*
29
+ ```
30
+
31
+ Env: `HANZO_S3_ENDPOINT` (default `s3.hanzo.ai`), `HANZO_S3_ACCESS_KEY`,
32
+ `HANZO_S3_SECRET_KEY`, `HANZO_S3_SECURE` (default `true`).
@@ -0,0 +1,15 @@
1
+ # hanzo-tools-s3
2
+
3
+ MCP tool for Hanzo S3 — S3-compatible object storage (s3.hanzo.ai).
4
+
5
+ Exposes a single `s3` tool with bucket and object actions: `buckets`,
6
+ `make_bucket`, `remove_bucket`, `objects`, `stat`, `remove_object`, `presign`.
7
+
8
+ Credentials come from the environment — inject them from KMS, never plaintext:
9
+
10
+ ```bash
11
+ eval "$(hanzo kms inject <project> <env>)" # exports HANZO_S3_*
12
+ ```
13
+
14
+ Env: `HANZO_S3_ENDPOINT` (default `s3.hanzo.ai`), `HANZO_S3_ACCESS_KEY`,
15
+ `HANZO_S3_SECRET_KEY`, `HANZO_S3_SECURE` (default `true`).
File without changes
@@ -0,0 +1,7 @@
1
+ """Hanzo S3 Tools — object storage via MCP."""
2
+
3
+ from .s3_tool import S3Tool
4
+
5
+ TOOLS = [S3Tool]
6
+
7
+ __all__ = ["S3Tool", "TOOLS"]
@@ -0,0 +1,245 @@
1
+ """MCP tool for Hanzo S3 — S3-compatible object storage.
2
+
3
+ Single tool over the ``hanzo_s3`` client (S3-compatible, backed by
4
+ hanzoai/s3) at s3.hanzo.ai.
5
+ Buckets and objects: list, create, remove, stat, and presign.
6
+
7
+ Auth: credentials from the environment — inject them from KMS, never store
8
+ plaintext:
9
+
10
+ eval "$(hanzo kms inject <project> <env>)" # exports HANZO_S3_*
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import json
17
+ import logging
18
+ from typing import Any, Annotated, final
19
+ from datetime import timedelta
20
+
21
+ from pydantic import Field
22
+ from mcp.server import FastMCP
23
+ from mcp.server.fastmcp import Context as MCPContext
24
+
25
+ from hanzo_tools.core.base import BaseTool
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ DESCRIPTION = """Hanzo S3 — S3-compatible object storage (s3.hanzo.ai).
30
+
31
+ Credentials from env: HANZO_S3_ENDPOINT, HANZO_S3_ACCESS_KEY, HANZO_S3_SECRET_KEY
32
+ (inject from KMS — never plaintext).
33
+
34
+ Bucket actions:
35
+ - buckets: List buckets
36
+ - make_bucket: Create a bucket (params: bucket)
37
+ - remove_bucket: Remove an empty bucket (params: bucket)
38
+
39
+ Object actions:
40
+ - objects: List objects (params: bucket, prefix, recursive)
41
+ - stat: Object metadata (params: bucket, object_name)
42
+ - remove_object: Delete an object (params: bucket, object_name)
43
+ - presign: Presigned GET URL (params: bucket, object_name, expires)
44
+ """
45
+
46
+
47
+ def _get_client():
48
+ """Build an S3 client from environment credentials."""
49
+ from hanzo_s3 import S3Client
50
+
51
+ endpoint = os.getenv("HANZO_S3_ENDPOINT", "s3.hanzo.ai")
52
+ access_key = os.getenv("HANZO_S3_ACCESS_KEY") or os.getenv("AWS_ACCESS_KEY_ID")
53
+ secret_key = os.getenv("HANZO_S3_SECRET_KEY") or os.getenv("AWS_SECRET_ACCESS_KEY")
54
+ secure = os.getenv("HANZO_S3_SECURE", "true").lower() not in ("0", "false", "no")
55
+
56
+ if not access_key or not secret_key:
57
+ raise RuntimeError(
58
+ "Missing S3 credentials. Set HANZO_S3_ACCESS_KEY and HANZO_S3_SECRET_KEY "
59
+ "(inject from KMS)."
60
+ )
61
+
62
+ return S3Client(endpoint, access_key=access_key, secret_key=secret_key, secure=secure)
63
+
64
+
65
+ @final
66
+ class S3Tool(BaseTool):
67
+ """MCP tool for Hanzo S3 object storage."""
68
+
69
+ @property
70
+ def name(self) -> str:
71
+ return "s3"
72
+
73
+ @property
74
+ def description(self) -> str:
75
+ return DESCRIPTION
76
+
77
+ async def call(
78
+ self,
79
+ ctx: MCPContext,
80
+ action: str = "buckets",
81
+ bucket: str | None = None,
82
+ object_name: str | None = None,
83
+ prefix: str = "",
84
+ recursive: bool = False,
85
+ expires: int = 3600,
86
+ **kwargs: Any,
87
+ ) -> str:
88
+ try:
89
+ if action == "buckets":
90
+ return self._buckets()
91
+ elif action == "make_bucket":
92
+ return self._make_bucket(bucket)
93
+ elif action == "remove_bucket":
94
+ return self._remove_bucket(bucket)
95
+ elif action == "objects":
96
+ return self._objects(bucket, prefix, recursive)
97
+ elif action == "stat":
98
+ return self._stat(bucket, object_name)
99
+ elif action == "remove_object":
100
+ return self._remove_object(bucket, object_name)
101
+ elif action == "presign":
102
+ return self._presign(bucket, object_name, expires)
103
+ else:
104
+ return json.dumps({
105
+ "error": f"Unknown action: {action}",
106
+ "available": [
107
+ "buckets", "make_bucket", "remove_bucket",
108
+ "objects", "stat", "remove_object", "presign",
109
+ ],
110
+ })
111
+ except RuntimeError as e:
112
+ return json.dumps({"error": str(e)})
113
+ except Exception as e:
114
+ logger.exception(f"S3 tool error: {e}")
115
+ return json.dumps({"error": f"S3 error: {e}"})
116
+
117
+ # -- Bucket actions ------------------------------------------------------
118
+
119
+ def _buckets(self) -> str:
120
+ client = _get_client()
121
+ result = [
122
+ {
123
+ "name": b.name,
124
+ "creation_date": str(b.creation_date) if b.creation_date else None,
125
+ }
126
+ for b in client.list_buckets()
127
+ ]
128
+ return json.dumps({"count": len(result), "buckets": result}, indent=2)
129
+
130
+ def _make_bucket(self, bucket: str | None) -> str:
131
+ if not bucket:
132
+ return json.dumps({"error": "Required: bucket"})
133
+ client = _get_client()
134
+ client.make_bucket(bucket)
135
+ return json.dumps({"action": "created", "bucket": bucket}, indent=2)
136
+
137
+ def _remove_bucket(self, bucket: str | None) -> str:
138
+ if not bucket:
139
+ return json.dumps({"error": "Required: bucket"})
140
+ client = _get_client()
141
+ client.remove_bucket(bucket)
142
+ return json.dumps({"action": "removed", "bucket": bucket}, indent=2)
143
+
144
+ # -- Object actions ------------------------------------------------------
145
+
146
+ def _objects(self, bucket: str | None, prefix: str, recursive: bool) -> str:
147
+ if not bucket:
148
+ return json.dumps({"error": "Required: bucket"})
149
+ client = _get_client()
150
+ result = [
151
+ {
152
+ "key": o.object_name,
153
+ "size": None if o.is_dir else o.size,
154
+ "last_modified": str(o.last_modified) if o.last_modified else None,
155
+ "is_dir": o.is_dir,
156
+ }
157
+ for o in client.list_objects(bucket, prefix=prefix, recursive=recursive)
158
+ ]
159
+ return json.dumps(
160
+ {"bucket": bucket, "prefix": prefix, "count": len(result), "objects": result},
161
+ indent=2,
162
+ )
163
+
164
+ def _stat(self, bucket: str | None, object_name: str | None) -> str:
165
+ if not bucket or not object_name:
166
+ return json.dumps({"error": "Required: bucket, object_name"})
167
+ client = _get_client()
168
+ obj = client.stat_object(bucket, object_name)
169
+ return json.dumps({
170
+ "bucket": bucket,
171
+ "object_name": object_name,
172
+ "size": obj.size,
173
+ "etag": obj.etag,
174
+ "content_type": obj.content_type,
175
+ "last_modified": str(obj.last_modified) if obj.last_modified else None,
176
+ }, indent=2)
177
+
178
+ def _remove_object(self, bucket: str | None, object_name: str | None) -> str:
179
+ if not bucket or not object_name:
180
+ return json.dumps({"error": "Required: bucket, object_name"})
181
+ client = _get_client()
182
+ client.remove_object(bucket, object_name)
183
+ return json.dumps({"action": "removed", "bucket": bucket, "object_name": object_name}, indent=2)
184
+
185
+ def _presign(self, bucket: str | None, object_name: str | None, expires: int) -> str:
186
+ if not bucket or not object_name:
187
+ return json.dumps({"error": "Required: bucket, object_name"})
188
+ client = _get_client()
189
+ url = client.presigned_get_object(
190
+ bucket, object_name, expires=timedelta(seconds=expires)
191
+ )
192
+ return json.dumps({"bucket": bucket, "object_name": object_name, "url": url}, indent=2)
193
+
194
+ # -- Registration --------------------------------------------------------
195
+
196
+ def register(self, mcp_server: FastMCP) -> None:
197
+ """Register S3 tool with explicit parameters."""
198
+ tool_instance = self
199
+
200
+ @mcp_server.tool(
201
+ name="s3",
202
+ description=DESCRIPTION,
203
+ )
204
+ async def s3(
205
+ action: Annotated[
206
+ str,
207
+ Field(
208
+ description=(
209
+ "Action to perform. "
210
+ "Buckets: buckets, make_bucket, remove_bucket. "
211
+ "Objects: objects, stat, remove_object, presign."
212
+ ),
213
+ ),
214
+ ] = "buckets",
215
+ bucket: Annotated[
216
+ str | None,
217
+ Field(description="Bucket name"),
218
+ ] = None,
219
+ object_name: Annotated[
220
+ str | None,
221
+ Field(description="Object key (for stat, remove_object, presign)"),
222
+ ] = None,
223
+ prefix: Annotated[
224
+ str,
225
+ Field(description="Object key prefix (for objects)"),
226
+ ] = "",
227
+ recursive: Annotated[
228
+ bool,
229
+ Field(description="Recurse into prefixes (for objects)"),
230
+ ] = False,
231
+ expires: Annotated[
232
+ int,
233
+ Field(description="Presigned URL lifetime in seconds (for presign)"),
234
+ ] = 3600,
235
+ ctx: MCPContext = None,
236
+ ) -> str:
237
+ return await tool_instance.call(
238
+ ctx,
239
+ action=action,
240
+ bucket=bucket,
241
+ object_name=object_name,
242
+ prefix=prefix,
243
+ recursive=recursive,
244
+ expires=expires,
245
+ )
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "hanzo-tools-s3"
3
+ version = "0.1.0"
4
+ description = "Hanzo S3 MCP tool — buckets and objects on S3-compatible storage"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Hanzo AI", email = "dev@hanzo.ai" }]
9
+ keywords = ["hanzo", "mcp", "s3", "seaweedfs", "object-storage", "tools"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ ]
17
+
18
+ dependencies = [
19
+ "hanzo-tools-core>=0.1.0",
20
+ "hanzo-s3>=1.0.0",
21
+ ]
22
+
23
+ [project.entry-points."hanzo.tools"]
24
+ s3 = "hanzo_tools.s3:TOOLS"
25
+
26
+ [build-system]
27
+ requires = ["hatchling"]
28
+ build-backend = "hatchling.build"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["hanzo_tools"]
@@ -0,0 +1,138 @@
1
+ """S3Tool test suite — action routing, validation, client wiring."""
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+
9
+ from hanzo_tools.s3.s3_tool import S3Tool
10
+
11
+
12
+ @pytest.fixture
13
+ def tool():
14
+ return S3Tool()
15
+
16
+
17
+ @pytest.fixture
18
+ def ctx():
19
+ return MagicMock()
20
+
21
+
22
+ def _patch_client(client):
23
+ return patch("hanzo_tools.s3.s3_tool._get_client", return_value=client)
24
+
25
+
26
+ class TestProperties:
27
+ def test_name(self, tool):
28
+ assert tool.name == "s3"
29
+
30
+ def test_description_sections(self, tool):
31
+ for section in ("Bucket actions", "Object actions"):
32
+ assert section in tool.description
33
+
34
+
35
+ class TestActionRouting:
36
+ @pytest.mark.asyncio
37
+ async def test_unknown_action_returns_error(self, tool, ctx):
38
+ result = json.loads(await tool.call(ctx, action="bogus"))
39
+ assert "Unknown action" in result["error"]
40
+
41
+ @pytest.mark.asyncio
42
+ async def test_unknown_action_lists_available(self, tool, ctx):
43
+ result = json.loads(await tool.call(ctx, action="bogus"))
44
+ assert set(result["available"]) == {
45
+ "buckets", "make_bucket", "remove_bucket",
46
+ "objects", "stat", "remove_object", "presign",
47
+ }
48
+
49
+
50
+ class TestBuckets:
51
+ @pytest.mark.asyncio
52
+ async def test_buckets(self, tool, ctx):
53
+ client = MagicMock()
54
+ bucket = MagicMock(name="hanzo-space", creation_date=datetime(2026, 1, 1))
55
+ bucket.name = "hanzo-space"
56
+ client.list_buckets.return_value = [bucket]
57
+ with _patch_client(client):
58
+ result = json.loads(await tool.call(ctx, action="buckets"))
59
+ assert result["count"] == 1
60
+ assert result["buckets"][0]["name"] == "hanzo-space"
61
+
62
+ @pytest.mark.asyncio
63
+ async def test_make_bucket_requires_name(self, tool, ctx):
64
+ result = json.loads(await tool.call(ctx, action="make_bucket"))
65
+ assert "Required" in result["error"]
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_make_bucket(self, tool, ctx):
69
+ client = MagicMock()
70
+ with _patch_client(client):
71
+ result = json.loads(await tool.call(ctx, action="make_bucket", bucket="b1"))
72
+ client.make_bucket.assert_called_once_with("b1")
73
+ assert result["action"] == "created"
74
+
75
+ @pytest.mark.asyncio
76
+ async def test_remove_bucket(self, tool, ctx):
77
+ client = MagicMock()
78
+ with _patch_client(client):
79
+ result = json.loads(await tool.call(ctx, action="remove_bucket", bucket="b1"))
80
+ client.remove_bucket.assert_called_once_with("b1")
81
+ assert result["action"] == "removed"
82
+
83
+
84
+ class TestObjects:
85
+ @pytest.mark.asyncio
86
+ async def test_objects_requires_bucket(self, tool, ctx):
87
+ result = json.loads(await tool.call(ctx, action="objects"))
88
+ assert "Required" in result["error"]
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_objects(self, tool, ctx):
92
+ client = MagicMock()
93
+ obj = MagicMock(object_name="a.txt", size=12, is_dir=False,
94
+ last_modified=datetime(2026, 1, 1))
95
+ client.list_objects.return_value = [obj]
96
+ with _patch_client(client):
97
+ result = json.loads(await tool.call(ctx, action="objects", bucket="b1", prefix="a"))
98
+ assert result["count"] == 1
99
+ assert result["objects"][0]["key"] == "a.txt"
100
+ client.list_objects.assert_called_once_with("b1", prefix="a", recursive=False)
101
+
102
+ @pytest.mark.asyncio
103
+ async def test_stat(self, tool, ctx):
104
+ client = MagicMock()
105
+ client.stat_object.return_value = MagicMock(
106
+ size=99, etag="abc", content_type="text/plain",
107
+ last_modified=datetime(2026, 1, 1),
108
+ )
109
+ with _patch_client(client):
110
+ result = json.loads(await tool.call(ctx, action="stat", bucket="b1", object_name="a.txt"))
111
+ assert result["size"] == 99
112
+ assert result["etag"] == "abc"
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_remove_object(self, tool, ctx):
116
+ client = MagicMock()
117
+ with _patch_client(client):
118
+ result = json.loads(await tool.call(ctx, action="remove_object", bucket="b1", object_name="a.txt"))
119
+ client.remove_object.assert_called_once_with("b1", "a.txt")
120
+ assert result["action"] == "removed"
121
+
122
+ @pytest.mark.asyncio
123
+ async def test_presign(self, tool, ctx):
124
+ client = MagicMock()
125
+ client.presigned_get_object.return_value = "https://s3.hanzo.ai/b1/a.txt?sig=x"
126
+ with _patch_client(client):
127
+ result = json.loads(await tool.call(ctx, action="presign", bucket="b1", object_name="a.txt"))
128
+ assert result["url"].startswith("https://s3.hanzo.ai/")
129
+
130
+
131
+ class TestCredentials:
132
+ @pytest.mark.asyncio
133
+ async def test_missing_creds_errors(self, tool, ctx, monkeypatch):
134
+ for var in ("HANZO_S3_ACCESS_KEY", "HANZO_S3_SECRET_KEY",
135
+ "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"):
136
+ monkeypatch.delenv(var, raising=False)
137
+ result = json.loads(await tool.call(ctx, action="buckets"))
138
+ assert "credentials" in result["error"].lower()