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.
- adapters/__init__.py +0 -0
- adapters/reference/__init__.py +0 -0
- adapters/reference/dynamic_remote_server.py +243 -0
- adapters/reference/remote_server.py +108 -0
- adapters/reference/server.py +389 -0
- adapters/tools.schema.json +17 -0
- cli/__init__.py +0 -0
- cli/main.py +2500 -0
- greft/__init__.py +5 -0
- greft-0.1.4.dist-info/METADATA +741 -0
- greft-0.1.4.dist-info/RECORD +57 -0
- greft-0.1.4.dist-info/WHEEL +4 -0
- greft-0.1.4.dist-info/entry_points.txt +3 -0
- greft-0.1.4.dist-info/licenses/LICENSE +191 -0
- protocol/__init__.py +0 -0
- protocol/canonical.py +19 -0
- protocol/crypto.py +80 -0
- protocol/envelope.py +58 -0
- protocol/errors.py +103 -0
- protocol/handoff.py +75 -0
- protocol/ids.py +120 -0
- protocol/schemas/artifact.json +22 -0
- protocol/schemas/envelope.json +123 -0
- protocol/schemas/handoff_payload.json +86 -0
- protocol/schemas.py +39 -0
- sdk/__init__.py +0 -0
- sdk/python/__init__.py +0 -0
- sdk/python/client.py +779 -0
- server/__init__.py +0 -0
- server/auth.py +340 -0
- server/authorization.py +84 -0
- server/config.py +131 -0
- server/database.py +76 -0
- server/delivery.py +85 -0
- server/email_templates.py +198 -0
- server/greft_agent.py +298 -0
- server/logging.py +109 -0
- server/main.py +174 -0
- server/models.py +551 -0
- server/plan_limits.py +84 -0
- server/presence.py +216 -0
- server/rate_limit.py +134 -0
- server/reaper.py +191 -0
- server/routers/__init__.py +3 -0
- server/routers/account.py +2579 -0
- server/routers/agents.py +249 -0
- server/routers/auth.py +78 -0
- server/routers/contacts.py +648 -0
- server/routers/conversations.py +168 -0
- server/routers/dashboard.py +345 -0
- server/routers/events.py +213 -0
- server/routers/messages.py +854 -0
- server/routers/permissions.py +267 -0
- server/routers/public.py +733 -0
- server/routers/sessions.py +360 -0
- server/security.py +110 -0
- 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
|
+
)
|