greft 0.1.4__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.
Files changed (57) hide show
  1. adapters/__init__.py +0 -0
  2. adapters/reference/__init__.py +0 -0
  3. adapters/reference/dynamic_remote_server.py +243 -0
  4. adapters/reference/remote_server.py +108 -0
  5. adapters/reference/server.py +389 -0
  6. adapters/tools.schema.json +17 -0
  7. cli/__init__.py +0 -0
  8. cli/main.py +2500 -0
  9. greft/__init__.py +5 -0
  10. greft-0.1.4.dist-info/METADATA +741 -0
  11. greft-0.1.4.dist-info/RECORD +57 -0
  12. greft-0.1.4.dist-info/WHEEL +4 -0
  13. greft-0.1.4.dist-info/entry_points.txt +3 -0
  14. greft-0.1.4.dist-info/licenses/LICENSE +191 -0
  15. protocol/__init__.py +0 -0
  16. protocol/canonical.py +19 -0
  17. protocol/crypto.py +80 -0
  18. protocol/envelope.py +58 -0
  19. protocol/errors.py +103 -0
  20. protocol/handoff.py +75 -0
  21. protocol/ids.py +120 -0
  22. protocol/schemas/artifact.json +22 -0
  23. protocol/schemas/envelope.json +123 -0
  24. protocol/schemas/handoff_payload.json +86 -0
  25. protocol/schemas.py +39 -0
  26. sdk/__init__.py +0 -0
  27. sdk/python/__init__.py +0 -0
  28. sdk/python/client.py +779 -0
  29. server/__init__.py +0 -0
  30. server/auth.py +340 -0
  31. server/authorization.py +84 -0
  32. server/config.py +131 -0
  33. server/database.py +76 -0
  34. server/delivery.py +85 -0
  35. server/email_templates.py +198 -0
  36. server/greft_agent.py +298 -0
  37. server/logging.py +109 -0
  38. server/main.py +174 -0
  39. server/models.py +551 -0
  40. server/plan_limits.py +84 -0
  41. server/presence.py +216 -0
  42. server/rate_limit.py +134 -0
  43. server/reaper.py +191 -0
  44. server/routers/__init__.py +3 -0
  45. server/routers/account.py +2579 -0
  46. server/routers/agents.py +249 -0
  47. server/routers/auth.py +78 -0
  48. server/routers/contacts.py +648 -0
  49. server/routers/conversations.py +168 -0
  50. server/routers/dashboard.py +345 -0
  51. server/routers/events.py +213 -0
  52. server/routers/messages.py +854 -0
  53. server/routers/permissions.py +267 -0
  54. server/routers/public.py +733 -0
  55. server/routers/sessions.py +360 -0
  56. server/security.py +110 -0
  57. server/user_auth.py +167 -0
adapters/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,243 @@
1
+ """Dynamic remote HTTP MCP server for Greft demo identities.
2
+
3
+ One deployed service can expose many prototype identities:
4
+
5
+ /@alice-demo/mcp -> @alice-demo
6
+ /@bob-demo/mcp -> @bob-demo
7
+
8
+ The identity is selected from the URL path. For this pre-demo, identities are
9
+ created automatically. If ``GREFT_DYNAMIC_IDENTITY_BUCKET`` is configured, this
10
+ legacy prototype can persist identity files to Google Cloud Storage. The current
11
+ Supabase-first direction should replace that with database-backed identity
12
+ ownership before any public production use.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import threading
21
+ from contextvars import ContextVar
22
+ from pathlib import Path
23
+ from urllib.parse import quote, unquote
24
+
25
+ import httpx
26
+ from starlette.responses import JSONResponse, PlainTextResponse
27
+ from starlette.types import ASGIApp, Receive, Scope, Send
28
+
29
+ from adapters.reference.server import create_server
30
+ from sdk.python.client import GreftClient
31
+
32
+ API_URL = os.environ.get("GREFT_API_URL", "http://localhost:8000")
33
+ IDENTITY_ROOT = Path(os.environ.get("GREFT_DYNAMIC_IDENTITY_ROOT", "/tmp/greft-dynamic-mcp")) # noqa: S108
34
+ GCS_BUCKET = os.environ.get("GREFT_DYNAMIC_IDENTITY_BUCKET", "")
35
+ GCS_PREFIX = os.environ.get("GREFT_DYNAMIC_IDENTITY_PREFIX", "identities").strip("/")
36
+ ADDRESS_RE = re.compile(r"^@[A-Za-z0-9][A-Za-z0-9._-]{1,62}$")
37
+
38
+ _current_address: ContextVar[str | None] = ContextVar("greft_current_address", default=None)
39
+ _locks: dict[str, threading.Lock] = {}
40
+ _locks_guard = threading.Lock()
41
+
42
+
43
+ def _address_slug(address: str) -> str:
44
+ return address.removeprefix("@")
45
+
46
+
47
+ def _validate_address(address: str) -> str:
48
+ address = address.strip()
49
+ if not ADDRESS_RE.fullmatch(address):
50
+ msg = (
51
+ "Invalid Greft address. Use a path like /@alice-demo1/mcp. "
52
+ "Allowed: @ plus letters, numbers, dot, underscore, or dash; 3-64 chars total."
53
+ )
54
+ raise ValueError(msg)
55
+ return address
56
+
57
+
58
+ def _home_for_address(address: str) -> Path:
59
+ return IDENTITY_ROOT / _address_slug(address)
60
+
61
+
62
+ def _lock_for(address: str) -> threading.Lock:
63
+ with _locks_guard:
64
+ lock = _locks.get(address)
65
+ if lock is None:
66
+ lock = threading.Lock()
67
+ _locks[address] = lock
68
+ return lock
69
+
70
+
71
+ def _metadata_token() -> str:
72
+ url = (
73
+ "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token"
74
+ )
75
+ with httpx.Client(timeout=10.0) as client:
76
+ resp = client.get(url, headers={"Metadata-Flavor": "Google"})
77
+ resp.raise_for_status()
78
+ return str(resp.json()["access_token"])
79
+
80
+
81
+ def _gcs_object_name(address: str, filename: str) -> str:
82
+ return f"{GCS_PREFIX}/{_address_slug(address)}/{filename}"
83
+
84
+
85
+ def _gcs_download(address: str, filename: str) -> bytes | None:
86
+ if not GCS_BUCKET:
87
+ return None
88
+ token = _metadata_token()
89
+ name = quote(_gcs_object_name(address, filename), safe="")
90
+ url = f"https://storage.googleapis.com/storage/v1/b/{GCS_BUCKET}/o/{name}?alt=media"
91
+ with httpx.Client(timeout=30.0) as client:
92
+ resp = client.get(url, headers={"Authorization": f"Bearer {token}"})
93
+ if resp.status_code == 404:
94
+ return None
95
+ resp.raise_for_status()
96
+ return resp.content
97
+
98
+
99
+ def _gcs_upload(address: str, filename: str, content: bytes) -> None:
100
+ if not GCS_BUCKET:
101
+ return
102
+ token = _metadata_token()
103
+ name = quote(_gcs_object_name(address, filename), safe="")
104
+ url = f"https://storage.googleapis.com/upload/storage/v1/b/{GCS_BUCKET}/o"
105
+ with httpx.Client(timeout=30.0) as client:
106
+ resp = client.post(
107
+ url,
108
+ params={"uploadType": "media", "name": name},
109
+ content=content,
110
+ headers={
111
+ "Authorization": f"Bearer {token}",
112
+ "Content-Type": "application/octet-stream",
113
+ },
114
+ )
115
+ resp.raise_for_status()
116
+
117
+
118
+ def _restore_identity(address: str, home: Path) -> None:
119
+ config = _gcs_download(address, "config.json")
120
+ key = _gcs_download(address, "private.key")
121
+ if config is None or key is None:
122
+ return
123
+ data = json.loads(config.decode())
124
+ agent_id = data["agent_id"]
125
+ home.mkdir(parents=True, exist_ok=True)
126
+ (home / "config.json").write_bytes(config)
127
+ keys_dir = home / "keys"
128
+ keys_dir.mkdir(parents=True, exist_ok=True)
129
+ key_path = keys_dir / f"{agent_id}.key"
130
+ key_path.write_bytes(key)
131
+ key_path.chmod(0o600)
132
+
133
+
134
+ def _persist_identity(address: str, home: Path, client: GreftClient) -> None:
135
+ if client.agent_id is None:
136
+ return
137
+ config_path = home / "config.json"
138
+ key_path = home / "keys" / f"{client.agent_id}.key"
139
+ if config_path.exists():
140
+ _gcs_upload(address, "config.json", config_path.read_bytes())
141
+ if key_path.exists():
142
+ _gcs_upload(address, "private.key", key_path.read_bytes())
143
+
144
+
145
+ def _ensure_identity(address: str) -> Path:
146
+ address = _validate_address(address)
147
+ home = _home_for_address(address)
148
+ with _lock_for(address):
149
+ if not (home / "config.json").exists():
150
+ _restore_identity(address, home)
151
+
152
+ with GreftClient(api_url=API_URL, home=home) as client:
153
+ if client.agent_id is None:
154
+ try:
155
+ client.init(address)
156
+ except httpx.HTTPStatusError as exc:
157
+ if exc.response.status_code == 409:
158
+ msg = (
159
+ f"{address} is already registered but this dynamic MCP service "
160
+ "does not have its private key. Choose a new demo address."
161
+ )
162
+ raise RuntimeError(msg) from exc
163
+ raise
164
+ elif client.address != address:
165
+ msg = f"{home} belongs to {client.address}, not {address}"
166
+ raise RuntimeError(msg)
167
+ client.ensure_session()
168
+ _persist_identity(address, home, client)
169
+ return home
170
+
171
+
172
+ def _get_dynamic_client() -> GreftClient:
173
+ address = _current_address.get()
174
+ if address is None:
175
+ msg = "No Greft address selected. Use /@address/mcp."
176
+ raise RuntimeError(msg)
177
+ home = _ensure_identity(address)
178
+ client = GreftClient(api_url=API_URL, home=home)
179
+ try:
180
+ client.ensure_session()
181
+ except Exception:
182
+ client.close()
183
+ raise
184
+ return client
185
+
186
+
187
+ async def _ready(scope: Scope, receive: Receive, send: Send) -> None:
188
+ response = JSONResponse(
189
+ {
190
+ "status": "ok",
191
+ "relay": API_URL,
192
+ "mode": "dynamic-url-identity",
193
+ "mcp_url_pattern": "/@address/mcp",
194
+ "identity_store": f"gs://{GCS_BUCKET}/{GCS_PREFIX}" if GCS_BUCKET else "local-only",
195
+ }
196
+ )
197
+ await response(scope, receive, send)
198
+
199
+
200
+ class DynamicIdentityMCPApp:
201
+ """ASGI dispatcher that maps /@address/mcp to the shared MCP app."""
202
+
203
+ def __init__(self, mcp_app: ASGIApp) -> None:
204
+ self._mcp_app = mcp_app
205
+
206
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
207
+ if scope["type"] != "http":
208
+ await self._mcp_app(scope, receive, send)
209
+ return
210
+
211
+ path = str(scope.get("path", ""))
212
+ if path in {"", "/", "/readyz"}:
213
+ await _ready(scope, receive, send)
214
+ return
215
+
216
+ parts = path.split("/")
217
+ if len(parts) >= 3 and parts[1].startswith("@") and parts[2] == "mcp":
218
+ address = _validate_address(unquote(parts[1]))
219
+ _ensure_identity(address)
220
+ token = _current_address.set(address)
221
+ try:
222
+ rewritten_scope = dict(scope)
223
+ rewritten_scope["path"] = "/" + "/".join(parts[2:])
224
+ rewritten_scope["raw_path"] = rewritten_scope["path"].encode()
225
+ await self._mcp_app(rewritten_scope, receive, send)
226
+ finally:
227
+ _current_address.reset(token)
228
+ return
229
+
230
+ response = PlainTextResponse(
231
+ "Use /@address/mcp, for example /@alice-demo1/mcp\n",
232
+ status_code=404,
233
+ )
234
+ await response(scope, receive, send)
235
+
236
+
237
+ server = create_server(client_factory=_get_dynamic_client)
238
+ _mcp_app = server.streamable_http_app(
239
+ streamable_http_path="/mcp",
240
+ stateless_http=True,
241
+ host="0.0.0.0", # noqa: S104
242
+ )
243
+ app = DynamicIdentityMCPApp(_mcp_app)
@@ -0,0 +1,108 @@
1
+ """Remote HTTP MCP server for one demo Greft identity.
2
+
3
+ Deploy one hosted service per demo profile:
4
+
5
+ greft-mcp-planner -> /mcp -> @planner-cloud-demo1
6
+ greft-mcp-developer -> /mcp -> @developer-cloud-demo1
7
+
8
+ This keeps the prototype simple while letting the MCP Streamable HTTP app own
9
+ its ASGI lifespan correctly.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import json
16
+ import os
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from starlette.responses import JSONResponse
21
+ from starlette.routing import Route
22
+
23
+ from adapters.reference.server import create_server
24
+ from sdk.python.client import GreftClient
25
+
26
+ API_URL = os.environ.get("GREFT_API_URL", "http://localhost:8000")
27
+ PROFILE = os.environ.get("GREFT_REMOTE_MCP_PROFILE", "planner").strip().lower()
28
+
29
+
30
+ def _env_name(suffix: str) -> str:
31
+ return f"GREFT_{PROFILE.upper()}_{suffix}"
32
+
33
+
34
+ def _profile_address() -> str:
35
+ return os.environ.get(_env_name("ADDRESS"), f"@{PROFILE}-cloud-demo")
36
+
37
+
38
+ def _profile_home() -> Path:
39
+ return Path(os.environ.get(_env_name("HOME"), f"/tmp/greft-remote-mcp/{PROFILE}")) # noqa: S108
40
+
41
+
42
+ def _restore_profile_home(home: Path) -> None:
43
+ config_b64 = os.environ.get(_env_name("CONFIG_B64"))
44
+ key_b64 = os.environ.get(_env_name("KEY_B64"))
45
+ if not config_b64 or not key_b64:
46
+ return
47
+
48
+ config = json.loads(base64.b64decode(config_b64).decode())
49
+ agent_id = config["agent_id"]
50
+ home.mkdir(parents=True, exist_ok=True)
51
+ (home / "config.json").write_text(json.dumps(config, indent=2))
52
+ keys_dir = home / "keys"
53
+ keys_dir.mkdir(parents=True, exist_ok=True)
54
+ key_path = keys_dir / f"{agent_id}.key"
55
+ key_path.write_bytes(base64.b64decode(key_b64))
56
+ key_path.chmod(0o600)
57
+
58
+
59
+ def _ensure_profile() -> dict[str, Any]:
60
+ address = _profile_address()
61
+ home = _profile_home()
62
+ _restore_profile_home(home)
63
+
64
+ with GreftClient(api_url=API_URL, home=home) as client:
65
+ if client.agent_id is None:
66
+ client.init(address)
67
+ elif client.address != address:
68
+ msg = f"{home} belongs to {client.address}, not {address}"
69
+ raise RuntimeError(msg)
70
+ client.ensure_session()
71
+ return {
72
+ "profile": PROFILE,
73
+ "address": client.address,
74
+ "agent_id": client.agent_id,
75
+ "home": str(home),
76
+ "mcp_path": "/mcp",
77
+ }
78
+
79
+
80
+ PROFILE_INFO = _ensure_profile()
81
+
82
+
83
+ async def readyz(_request: Any) -> JSONResponse:
84
+ return JSONResponse(
85
+ {
86
+ "status": "ok",
87
+ "relay": API_URL,
88
+ "profile": PROFILE_INFO["profile"],
89
+ "address": PROFILE_INFO["address"],
90
+ "mcp_path": PROFILE_INFO["mcp_path"],
91
+ }
92
+ )
93
+
94
+
95
+ async def index(_request: Any) -> JSONResponse:
96
+ return await readyz(_request)
97
+
98
+
99
+ server = create_server(home=PROFILE_INFO["home"])
100
+ app = server.streamable_http_app(
101
+ streamable_http_path="/mcp",
102
+ stateless_http=True,
103
+ host="0.0.0.0", # noqa: S104
104
+ custom_starlette_routes=[
105
+ Route("/", index, methods=["GET"]),
106
+ Route("/readyz", readyz, methods=["GET"]),
107
+ ],
108
+ )