proteworks 0.1.3__tar.gz → 0.1.4__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.
Files changed (56) hide show
  1. proteworks-0.1.4/PKG-INFO +116 -0
  2. proteworks-0.1.4/README.md +102 -0
  3. {proteworks-0.1.3 → proteworks-0.1.4}/pyproject.toml +3 -1
  4. {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/api.py +127 -1
  5. proteworks-0.1.4/src/proteworks/browse.py +49 -0
  6. {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/cli.py +34 -29
  7. {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/index.py +12 -2
  8. proteworks-0.1.4/src/proteworks/mcp/__init__.py +13 -0
  9. proteworks-0.1.4/src/proteworks/mcp/errors.py +32 -0
  10. proteworks-0.1.4/src/proteworks/mcp/paths.py +47 -0
  11. proteworks-0.1.4/src/proteworks/mcp/profile.py +30 -0
  12. proteworks-0.1.4/src/proteworks/mcp/runtime.py +47 -0
  13. proteworks-0.1.4/src/proteworks/mcp/server.py +64 -0
  14. proteworks-0.1.4/src/proteworks/mcp/tools_files.py +131 -0
  15. proteworks-0.1.4/src/proteworks/mcp/tools_orgs.py +139 -0
  16. proteworks-0.1.4/src/proteworks/mcp/tools_studio.py +393 -0
  17. proteworks-0.1.4/src/proteworks/mcp/tools_workflows.py +343 -0
  18. {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/pull.py +88 -41
  19. {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/push.py +51 -25
  20. proteworks-0.1.4/src/proteworks/results.py +42 -0
  21. proteworks-0.1.4/src/proteworks/session.py +77 -0
  22. proteworks-0.1.4/src/proteworks/studio/__init__.py +16 -0
  23. proteworks-0.1.4/src/proteworks/studio/common.py +213 -0
  24. proteworks-0.1.4/src/proteworks/studio/kinds/__init__.py +25 -0
  25. proteworks-0.1.4/src/proteworks/studio/kinds/base.py +98 -0
  26. proteworks-0.1.4/src/proteworks/studio/kinds/proteinmpnn.py +214 -0
  27. proteworks-0.1.4/src/proteworks/studio/kinds/refolding.py +120 -0
  28. proteworks-0.1.4/src/proteworks/studio/kinds/rfd3.py +93 -0
  29. proteworks-0.1.4/src/proteworks/studio/kinds/structure_prediction.py +124 -0
  30. proteworks-0.1.4/src/proteworks/studio/registry.py +49 -0
  31. proteworks-0.1.4/src/proteworks/studio_proteinmpnn.py +37 -0
  32. proteworks-0.1.4/src/proteworks/workflows.py +704 -0
  33. proteworks-0.1.4/src/proteworks.egg-info/PKG-INFO +116 -0
  34. proteworks-0.1.4/src/proteworks.egg-info/SOURCES.txt +50 -0
  35. {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks.egg-info/entry_points.txt +1 -0
  36. {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks.egg-info/requires.txt +4 -0
  37. proteworks-0.1.4/tests/test_browse.py +49 -0
  38. {proteworks-0.1.3 → proteworks-0.1.4}/tests/test_cli.py +519 -5
  39. proteworks-0.1.4/tests/test_mcp_adversarial.py +142 -0
  40. proteworks-0.1.4/tests/test_mcp_files.py +138 -0
  41. proteworks-0.1.4/tests/test_mcp_studio.py +290 -0
  42. proteworks-0.1.4/tests/test_mcp_workflows.py +436 -0
  43. proteworks-0.1.4/tests/test_session.py +45 -0
  44. proteworks-0.1.4/tests/test_transfer_result.py +123 -0
  45. proteworks-0.1.3/PKG-INFO +0 -28
  46. proteworks-0.1.3/README.md +0 -17
  47. proteworks-0.1.3/src/proteworks.egg-info/PKG-INFO +0 -28
  48. proteworks-0.1.3/src/proteworks.egg-info/SOURCES.txt +0 -19
  49. {proteworks-0.1.3 → proteworks-0.1.4}/setup.cfg +0 -0
  50. {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/__init__.py +0 -0
  51. {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/auth.py +0 -0
  52. {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/config.py +0 -0
  53. {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/orgs.py +0 -0
  54. {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/redact.py +0 -0
  55. {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks.egg-info/dependency_links.txt +0 -0
  56. {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks.egg-info/top_level.txt +0 -0
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: proteworks
3
+ Version: 0.1.4
4
+ Summary: Sync files with Proteworks using file-browser paths.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: keyring>=25
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8; extra == "dev"
11
+ Provides-Extra: mcp
12
+ Requires-Dist: mcp>=1.2; extra == "mcp"
13
+ Requires-Dist: pyarrow>=14; extra == "mcp"
14
+
15
+ # proteworks
16
+
17
+ Command line client and local MCP server for syncing Proteworks files and submitting allowlisted workflows. The default API is production. Set `PROTEWORKS_API_URL` only when testing against staging or a local stack.
18
+
19
+ ```bash
20
+ uv tool install proteworks
21
+ proteworks login
22
+ proteworks organization list
23
+ proteworks organization set "My Org"
24
+ proteworks pull '~/My Files/campaigns/run/' ./run
25
+ proteworks push ./1UBQ.pdb '~/My Files/test/'
26
+ proteworks push ./run '~/My Files/campaigns/run/'
27
+ ```
28
+
29
+ Quote wildcards so the shell does not expand them. A trailing slash on a remote path means a folder. `proteworks --help` lists every command.
30
+
31
+ Login returns to `http://127.0.0.1:8765/callback`. Add that exact URL to the Auth0 application's Allowed Callback URLs. The same URL is used in development and production.
32
+
33
+ ## MCP (Claude Science and other clients)
34
+
35
+ The same login and organization selection power a local MCP server. Install the MCP extra, sign in once with the CLI, then point your client at `proteworks-mcp`.
36
+
37
+ ```bash
38
+ uv tool install 'proteworks[mcp]'
39
+ # or: pip install 'proteworks[mcp]'
40
+ proteworks login
41
+ proteworks organization set "My Org"
42
+ ```
43
+
44
+ ### Claude Science
45
+
46
+ 1. Settings → Connectors → Add connector → **Local command**
47
+ 2. Command: `proteworks-mcp`
48
+ (or `uvx --from 'proteworks[mcp]' proteworks-mcp`)
49
+ 3. Approve tools as needed
50
+
51
+ Example prompts: list `~/My Files/`, dry-run pull a folder, pull it, push results back.
52
+
53
+ Optional coaching skills (not required for the server):
54
+
55
+ - [`skills/proteworks-files/SKILL.md`](skills/proteworks-files/SKILL.md)
56
+ - [`skills/proteworks-workflows/SKILL.md`](skills/proteworks-workflows/SKILL.md)
57
+
58
+ Import via Settings → Skills → Import from GitHub (or upload the folder). Hosts without a Skills UI still get conventions from MCP tool descriptions and this README.
59
+
60
+ ### Tools
61
+
62
+ | Tool | Purpose |
63
+ | --- | --- |
64
+ | `auth_status` | Login state, API URL, active org (never the token) |
65
+ | `organization_list` / `organization_set` | Choose organization (`organization_set` needs `confirm=true`) |
66
+ | `list_files` | Browse a remote path; returns `file_id` + `relative_path` |
67
+ | `pull` / `push` | Transfer with `dry_run` (default true); real transfers need `confirm_transfer=true` |
68
+ | `list_workflows` | Allowlisted agent workflows (`rf-diffusion3`, `proteinmpnn`, `refolding`, `structure-prediction`, `msa-generation`) |
69
+ | `get_workflow_schema` | Full schema with UI-matching defaults |
70
+ | `prepare_workflow_submission` | Merge defaults + overrides; **no submit** (draft body) |
71
+ | `submit_workflow` | Same merge, then submit |
72
+ | `inspect_studio` | Inspect supported `.studio` (proteinmpnn / refolding / rf-diffusion3 / structure_prediction); normalized rows + scores |
73
+ | `save_selected_studio` | Same as Studio Save selected → subset `.studio` uploaded + retention finalized (not yet for structure_prediction) |
74
+ | `inspect_proteinmpnn_studio` | Legacy ProteinMPNN flat-row inspect (prefer `inspect_studio`) |
75
+ | `prepare_refolding_from_proteinmpnn_studio` | Build refolding payload + SA handoff from MPNN studio selection (**no submit**) |
76
+ | `get_execution` / `list_execution_results` / `cancel_execution` | Status / results / cancel (`cancel_execution` needs `confirm=true`) |
77
+ | `estimate_workflow_cost` | Cost estimate from merged params (same validation as prepare) |
78
+
79
+ Auth is shared with the CLI (OS keychain + `~/.config/proteworks/`). There is no separate MCP login.
80
+
81
+ ### MCP safety env
82
+
83
+ | Variable | Purpose |
84
+ | --- | --- |
85
+ | `PROTEWORKS_MCP_ROOT` | Local workspace root for `pull`/`push` (default: process cwd) |
86
+ | `PROTEWORKS_MCP_READ_ONLY` | When `1`/`true`, disables submit/push/cancel/save-selected |
87
+ | `PROTEWORKS_STORAGE_HOST_ALLOWLIST` | Comma-separated host suffixes for signed URL follow (default: GCS hosts) |
88
+ | `PROTEWORKS_MAX_STUDIO_BYTES` | Max `.studio` download size (default 80 MiB) |
89
+ | `PROTEWORKS_STUDIO_INSPECT_LIMIT` | Default row page size for `inspect_studio` (default 50) |
90
+
91
+ **Staging smoke:** `auth_status` → `list_workflows` → `get_workflow_schema` → `prepare_workflow_submission` → `estimate_workflow_cost` → `submit_workflow` → `get_execution` → `cancel_execution` (with `confirm=true` if still running).
92
+
93
+ ### Workflow submit
94
+
95
+ 1. `list_files` → get `file_id` for any structure input.
96
+ 2. `get_workflow_schema` for the workflow (`rf-diffusion3`, `proteinmpnn`, `refolding`, `structure-prediction`, or `msa-generation`).
97
+ 3. `prepare_workflow_submission` to review the merged payload (defaults match a fresh Studio form).
98
+ 4. `submit_workflow` when ready.
99
+
100
+ **Refolding** and **Structure Prediction** have no default predictor/MSA — agents must **ask the user** which methods and (when required) which MSA to use before prepare/submit. Structure Prediction also requires a user-provided complex (`structure-prediction-complex-json`). **MSA generation** has no default sequences — ask, then pass `generated-fasta-inputs` or `fasta_files`; use the run **output folder** as `msa_output_folder` downstream. From ProteinMPNN results: `inspect_studio` → ask → `prepare_refolding_from_proteinmpnn_studio` → `submit_workflow`.
101
+
102
+ **`.studio` results:** `inspect_studio` → filter by scores (e.g. pLDDT / Rg) → `save_selected_studio` to write a subset bundle (structure_prediction is inspect-only for now).
103
+
104
+ Payload shape matches Studio `POST /api/v1/workflows/submit` and is reserved for future shareable draft links (`/app?workflowDraft=<id>`) that open the form pre-filled without submitting.
105
+
106
+ ### Adding another workflow to the allowlist
107
+
108
+ In **protein-studio**:
109
+
110
+ 1. Extract `buildXDefaultParameters` + form init from that workflow’s UI.
111
+ 2. Implement `buildXAgentSchema()`.
112
+ 3. Run `npm run export-agent-schemas` in `frontend/`; commit JSON under `backend/app/data/agent_workflow_schemas/`.
113
+ 4. Add the workflow id to allowlists (frontend export index, backend `AGENT_WORKFLOW_ALLOWLIST`, proteworks-cli `AGENT_WORKFLOW_ALLOWLIST`).
114
+ 5. Add default/parity tests; extend the workflows skill if needed.
115
+
116
+ No new MCP tool names are required.
@@ -0,0 +1,102 @@
1
+ # proteworks
2
+
3
+ Command line client and local MCP server for syncing Proteworks files and submitting allowlisted workflows. The default API is production. Set `PROTEWORKS_API_URL` only when testing against staging or a local stack.
4
+
5
+ ```bash
6
+ uv tool install proteworks
7
+ proteworks login
8
+ proteworks organization list
9
+ proteworks organization set "My Org"
10
+ proteworks pull '~/My Files/campaigns/run/' ./run
11
+ proteworks push ./1UBQ.pdb '~/My Files/test/'
12
+ proteworks push ./run '~/My Files/campaigns/run/'
13
+ ```
14
+
15
+ Quote wildcards so the shell does not expand them. A trailing slash on a remote path means a folder. `proteworks --help` lists every command.
16
+
17
+ Login returns to `http://127.0.0.1:8765/callback`. Add that exact URL to the Auth0 application's Allowed Callback URLs. The same URL is used in development and production.
18
+
19
+ ## MCP (Claude Science and other clients)
20
+
21
+ The same login and organization selection power a local MCP server. Install the MCP extra, sign in once with the CLI, then point your client at `proteworks-mcp`.
22
+
23
+ ```bash
24
+ uv tool install 'proteworks[mcp]'
25
+ # or: pip install 'proteworks[mcp]'
26
+ proteworks login
27
+ proteworks organization set "My Org"
28
+ ```
29
+
30
+ ### Claude Science
31
+
32
+ 1. Settings → Connectors → Add connector → **Local command**
33
+ 2. Command: `proteworks-mcp`
34
+ (or `uvx --from 'proteworks[mcp]' proteworks-mcp`)
35
+ 3. Approve tools as needed
36
+
37
+ Example prompts: list `~/My Files/`, dry-run pull a folder, pull it, push results back.
38
+
39
+ Optional coaching skills (not required for the server):
40
+
41
+ - [`skills/proteworks-files/SKILL.md`](skills/proteworks-files/SKILL.md)
42
+ - [`skills/proteworks-workflows/SKILL.md`](skills/proteworks-workflows/SKILL.md)
43
+
44
+ Import via Settings → Skills → Import from GitHub (or upload the folder). Hosts without a Skills UI still get conventions from MCP tool descriptions and this README.
45
+
46
+ ### Tools
47
+
48
+ | Tool | Purpose |
49
+ | --- | --- |
50
+ | `auth_status` | Login state, API URL, active org (never the token) |
51
+ | `organization_list` / `organization_set` | Choose organization (`organization_set` needs `confirm=true`) |
52
+ | `list_files` | Browse a remote path; returns `file_id` + `relative_path` |
53
+ | `pull` / `push` | Transfer with `dry_run` (default true); real transfers need `confirm_transfer=true` |
54
+ | `list_workflows` | Allowlisted agent workflows (`rf-diffusion3`, `proteinmpnn`, `refolding`, `structure-prediction`, `msa-generation`) |
55
+ | `get_workflow_schema` | Full schema with UI-matching defaults |
56
+ | `prepare_workflow_submission` | Merge defaults + overrides; **no submit** (draft body) |
57
+ | `submit_workflow` | Same merge, then submit |
58
+ | `inspect_studio` | Inspect supported `.studio` (proteinmpnn / refolding / rf-diffusion3 / structure_prediction); normalized rows + scores |
59
+ | `save_selected_studio` | Same as Studio Save selected → subset `.studio` uploaded + retention finalized (not yet for structure_prediction) |
60
+ | `inspect_proteinmpnn_studio` | Legacy ProteinMPNN flat-row inspect (prefer `inspect_studio`) |
61
+ | `prepare_refolding_from_proteinmpnn_studio` | Build refolding payload + SA handoff from MPNN studio selection (**no submit**) |
62
+ | `get_execution` / `list_execution_results` / `cancel_execution` | Status / results / cancel (`cancel_execution` needs `confirm=true`) |
63
+ | `estimate_workflow_cost` | Cost estimate from merged params (same validation as prepare) |
64
+
65
+ Auth is shared with the CLI (OS keychain + `~/.config/proteworks/`). There is no separate MCP login.
66
+
67
+ ### MCP safety env
68
+
69
+ | Variable | Purpose |
70
+ | --- | --- |
71
+ | `PROTEWORKS_MCP_ROOT` | Local workspace root for `pull`/`push` (default: process cwd) |
72
+ | `PROTEWORKS_MCP_READ_ONLY` | When `1`/`true`, disables submit/push/cancel/save-selected |
73
+ | `PROTEWORKS_STORAGE_HOST_ALLOWLIST` | Comma-separated host suffixes for signed URL follow (default: GCS hosts) |
74
+ | `PROTEWORKS_MAX_STUDIO_BYTES` | Max `.studio` download size (default 80 MiB) |
75
+ | `PROTEWORKS_STUDIO_INSPECT_LIMIT` | Default row page size for `inspect_studio` (default 50) |
76
+
77
+ **Staging smoke:** `auth_status` → `list_workflows` → `get_workflow_schema` → `prepare_workflow_submission` → `estimate_workflow_cost` → `submit_workflow` → `get_execution` → `cancel_execution` (with `confirm=true` if still running).
78
+
79
+ ### Workflow submit
80
+
81
+ 1. `list_files` → get `file_id` for any structure input.
82
+ 2. `get_workflow_schema` for the workflow (`rf-diffusion3`, `proteinmpnn`, `refolding`, `structure-prediction`, or `msa-generation`).
83
+ 3. `prepare_workflow_submission` to review the merged payload (defaults match a fresh Studio form).
84
+ 4. `submit_workflow` when ready.
85
+
86
+ **Refolding** and **Structure Prediction** have no default predictor/MSA — agents must **ask the user** which methods and (when required) which MSA to use before prepare/submit. Structure Prediction also requires a user-provided complex (`structure-prediction-complex-json`). **MSA generation** has no default sequences — ask, then pass `generated-fasta-inputs` or `fasta_files`; use the run **output folder** as `msa_output_folder` downstream. From ProteinMPNN results: `inspect_studio` → ask → `prepare_refolding_from_proteinmpnn_studio` → `submit_workflow`.
87
+
88
+ **`.studio` results:** `inspect_studio` → filter by scores (e.g. pLDDT / Rg) → `save_selected_studio` to write a subset bundle (structure_prediction is inspect-only for now).
89
+
90
+ Payload shape matches Studio `POST /api/v1/workflows/submit` and is reserved for future shareable draft links (`/app?workflowDraft=<id>`) that open the form pre-filled without submitting.
91
+
92
+ ### Adding another workflow to the allowlist
93
+
94
+ In **protein-studio**:
95
+
96
+ 1. Extract `buildXDefaultParameters` + form init from that workflow’s UI.
97
+ 2. Implement `buildXAgentSchema()`.
98
+ 3. Run `npm run export-agent-schemas` in `frontend/`; commit JSON under `backend/app/data/agent_workflow_schemas/`.
99
+ 4. Add the workflow id to allowlists (frontend export index, backend `AGENT_WORKFLOW_ALLOWLIST`, proteworks-cli `AGENT_WORKFLOW_ALLOWLIST`).
100
+ 5. Add default/parity tests; extend the workflows skill if needed.
101
+
102
+ No new MCP tool names are required.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "proteworks"
3
- version = "0.1.3"
3
+ version = "0.1.4"
4
4
  description = "Sync files with Proteworks using file-browser paths."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -11,9 +11,11 @@ dependencies = [
11
11
 
12
12
  [project.optional-dependencies]
13
13
  dev = ["pytest>=8"]
14
+ mcp = ["mcp>=1.2", "pyarrow>=14"]
14
15
 
15
16
  [project.scripts]
16
17
  proteworks = "proteworks.cli:main"
18
+ proteworks-mcp = "proteworks.mcp.server:main"
17
19
 
18
20
  [build-system]
19
21
  requires = ["setuptools>=68"]
@@ -2,14 +2,29 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import time
6
7
  from typing import Any, Optional
7
- from urllib.parse import urlencode
8
+ from urllib.parse import urlencode, urlparse
8
9
 
9
10
  import httpx
10
11
 
11
12
  from proteworks.redact import redact
12
13
 
14
+ # Host suffixes allowed for signed download/upload URLs (comma-separated env override).
15
+ _DEFAULT_STORAGE_HOST_SUFFIXES = (
16
+ "storage.googleapis.com",
17
+ "googleapis.com",
18
+ "googleusercontent.com",
19
+ )
20
+
21
+
22
+ def allowed_storage_host_suffixes() -> tuple[str, ...]:
23
+ raw = (os.environ.get("PROTEWORKS_STORAGE_HOST_ALLOWLIST") or "").strip()
24
+ if raw:
25
+ return tuple(part.strip().lower() for part in raw.split(",") if part.strip())
26
+ return _DEFAULT_STORAGE_HOST_SUFFIXES
27
+
13
28
 
14
29
  class ApiError(Exception):
15
30
  """Sanitized API failure. The message never includes a storage URL."""
@@ -19,6 +34,20 @@ class ApiError(Exception):
19
34
  self.status_code = status_code
20
35
 
21
36
 
37
+ def assert_allowed_storage_url(url: str) -> None:
38
+ """Reject signed URLs whose host is outside the storage allowlist (SSRF defense)."""
39
+ parsed = urlparse(str(url or ""))
40
+ if parsed.scheme not in {"https", "http"}:
41
+ raise ApiError("Storage URL must use http(s)", 400)
42
+ host = (parsed.hostname or "").lower()
43
+ if not host:
44
+ raise ApiError("Storage URL missing host", 400)
45
+ suffixes = allowed_storage_host_suffixes()
46
+ if any(host == suffix or host.endswith("." + suffix) for suffix in suffixes):
47
+ return
48
+ raise ApiError("Storage URL host is not allowlisted", 400)
49
+
50
+
22
51
  def public_detail(response: httpx.Response) -> str:
23
52
  """User-facing error from a JSON body, without echoing storage fields."""
24
53
  try:
@@ -192,6 +221,7 @@ class ApiClient:
192
221
 
193
222
  def download_to(self, url: str, path) -> None:
194
223
  """Stream bytes to a file. The URL is not logged."""
224
+ assert_allowed_storage_url(url)
195
225
  with self._client.stream("GET", url) as response:
196
226
  response.raise_for_status()
197
227
  with open(path, "wb") as handle:
@@ -200,7 +230,103 @@ class ApiClient:
200
230
 
201
231
  def put_file(self, url: str, path, headers: Optional[dict] = None) -> None:
202
232
  """Upload a file. The URL is not logged."""
233
+ assert_allowed_storage_url(url)
203
234
  with open(path, "rb") as handle:
204
235
  response = self._client.put(url, content=handle, headers=headers or {})
205
236
  if response.status_code >= 400:
206
237
  raise ApiError(f"Upload to storage failed ({response.status_code})", response.status_code)
238
+
239
+ def list_agent_schemas(self) -> dict:
240
+ return self.request("GET", "/api/v1/workflows/agent-schemas")
241
+
242
+ def get_agent_schema(self, workflow_name: str) -> dict:
243
+ return self.request("GET", f"/api/v1/workflows/agent-schemas/{workflow_name}")
244
+
245
+ def submit_workflow(self, body: dict) -> dict:
246
+ return self.request("POST", "/api/v1/workflows/submit", json_body=body)
247
+
248
+ def get_execution(self, execution_id: str) -> dict:
249
+ return self.request("GET", f"/api/v1/workflows/executions/{execution_id}")
250
+
251
+ def list_execution_results(self, execution_id: str) -> list:
252
+ payload = self.request("GET", f"/api/v1/workflows/executions/{execution_id}/results")
253
+ return list(payload or [])
254
+
255
+ def cancel_execution(self, execution_id: str) -> dict:
256
+ return self.request("POST", f"/api/v1/workflows/executions/{execution_id}/cancel")
257
+
258
+ def estimate_workflow_cost(self, body: dict) -> dict:
259
+ return self.request("POST", "/api/v1/workflows/estimate-cost", json_body=body)
260
+
261
+ def upload_bytes(
262
+ self,
263
+ filename: str,
264
+ folder_id: str,
265
+ data: bytes,
266
+ content_type: str = "application/octet-stream",
267
+ ) -> dict:
268
+ """Upload bytes via upload-init / PUT / upload-complete."""
269
+ init = self.upload_init(filename, folder_id, len(data), content_type)
270
+ if init.get("skipped"):
271
+ return {"id": init.get("file_id") or init.get("id")}
272
+ upload_url = init.get("upload_url")
273
+ session_id = init.get("session_id")
274
+ if not upload_url or not session_id:
275
+ raise ApiError("Upload init missing upload_url or session_id", 500)
276
+ assert_allowed_storage_url(str(upload_url))
277
+ response = self._client.put(
278
+ upload_url,
279
+ content=data,
280
+ headers={"Content-Type": content_type},
281
+ )
282
+ if response.status_code >= 400:
283
+ raise ApiError(f"Upload to storage failed ({response.status_code})", response.status_code)
284
+ return self.upload_complete(session_id, len(data))
285
+
286
+ def upload_json_bytes(
287
+ self,
288
+ filename: str,
289
+ folder_id: str,
290
+ data: bytes,
291
+ content_type: str = "application/json",
292
+ ) -> dict:
293
+ """Upload small JSON (e.g. SA handoff) via upload-init / PUT / upload-complete."""
294
+ return self.upload_bytes(filename, folder_id, data, content_type=content_type)
295
+
296
+ def save_selected_studio(
297
+ self,
298
+ file_id: str,
299
+ route_slug: str,
300
+ design_ids: list[str],
301
+ ) -> bytes:
302
+ """POST ``/files/{id}/{route_slug}-save-selected``; return subset .studio bytes."""
303
+ slug = str(route_slug or "").strip().strip("/")
304
+ if not slug:
305
+ raise ApiError("route_slug is required for save-selected", 400)
306
+ path = f"/api/v1/files/{file_id}/{slug}-save-selected"
307
+ query: dict[str, str] = {}
308
+ if self.organization_id:
309
+ query["organization_id"] = self.organization_id
310
+ url = self.base_url + path
311
+ if query:
312
+ url = url + "?" + urlencode(query)
313
+ headers = self._headers()
314
+ headers["Accept"] = "application/zip, application/octet-stream, */*"
315
+ response = self._client.post(
316
+ url,
317
+ headers=headers,
318
+ json={"design_ids": list(design_ids)},
319
+ )
320
+ if response.status_code >= 400:
321
+ raise ApiError(public_detail(response), response.status_code)
322
+ return response.content
323
+
324
+ def finalize_studio_bundle_retention(self, file_id: str, route_slug: str) -> dict:
325
+ """POST ``/files/{id}/{route_slug}-bundle-finalize-retention``."""
326
+ slug = str(route_slug or "").strip().strip("/")
327
+ if not slug:
328
+ raise ApiError("route_slug is required for finalize-retention", 400)
329
+ return self.request(
330
+ "POST",
331
+ f"/api/v1/files/{file_id}/{slug}-bundle-finalize-retention",
332
+ )
@@ -0,0 +1,49 @@
1
+ """List remote file-browser paths without transferring bytes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from proteworks.api import ApiClient, ApiError
8
+
9
+
10
+ def list_remote(api: ApiClient, path: str, *, limit: int = 200) -> dict[str, Any]:
11
+ """
12
+ List files under a file-browser path.
13
+
14
+ Returns agent-safe fields only (includes file_id for future cloud submit).
15
+ Caps the item list at ``limit`` and sets truncated=true when more remain.
16
+ """
17
+ if limit < 1:
18
+ raise ValueError("limit must be at least 1")
19
+ try:
20
+ page = api.manifest_all(path)
21
+ except ApiError as exc:
22
+ return {
23
+ "ok": False,
24
+ "error": str(exc),
25
+ "items": [],
26
+ "total": 0,
27
+ "root_folder_id": None,
28
+ "truncated": False,
29
+ }
30
+ raw_items = list(page.get("items") or [])
31
+ total = int(page.get("total") or len(raw_items))
32
+ truncated = len(raw_items) > limit
33
+ items = [_normalize_item(item) for item in raw_items[:limit]]
34
+ return {
35
+ "ok": True,
36
+ "items": items,
37
+ "total": total,
38
+ "root_folder_id": page.get("root_folder_id"),
39
+ "truncated": truncated,
40
+ }
41
+
42
+
43
+ def _normalize_item(item: dict) -> dict[str, Any]:
44
+ return {
45
+ "file_id": item.get("file_id"),
46
+ "relative_path": item.get("relative_path"),
47
+ "size": item.get("size"),
48
+ "sha256": item.get("sha256"),
49
+ }
@@ -19,6 +19,7 @@ from proteworks.orgs import (
19
19
  from proteworks.pull import export_organization, pull_path
20
20
  from proteworks.push import push_directory
21
21
  from proteworks.redact import install_redacting_logs
22
+ from proteworks.session import SessionError, authenticated_client, open_session
22
23
 
23
24
  _PULL_EXAMPLES = """\
24
25
  examples:
@@ -127,35 +128,47 @@ def main(argv: list[str] | None = None, *, settings: Settings | None = None, tok
127
128
  data["api_url"] = settings.api_url
128
129
  config.write(data)
129
130
  return 0
130
- token = token_store.get()
131
- if not token:
132
- print("Run proteworks login.", file=sys.stderr)
133
- return 2
134
- config = ConfigStore(settings.config_dir / "config.json")
135
- bound = config.bound_api_url()
136
- if bound and bound.rstrip("/") != settings.api_url.rstrip("/"):
137
- print("API URL changed. Run proteworks login again.", file=sys.stderr)
138
- return 2
139
- api = ApiClient(settings.api_url, token)
140
- try:
141
- if args.command == "organization" and args.org_command == "list":
131
+ if args.command == "organization" and args.org_command == "list":
132
+ auth = authenticated_client(settings=settings, token_store=token_store)
133
+ if isinstance(auth, SessionError):
134
+ print(auth.message, file=sys.stderr)
135
+ return 2
136
+ api, config = auth
137
+ try:
142
138
  return _organization_list(api, config, sys.stdout)
143
- if args.command == "organization" and args.org_command == "set":
144
- return _organization_set(api, config, settings, args.name, sys.stderr)
145
- org = _require_organization(api, config, sys.stderr)
146
- if org is None:
139
+ finally:
140
+ api.close()
141
+ if args.command == "organization" and args.org_command == "set":
142
+ auth = authenticated_client(settings=settings, token_store=token_store)
143
+ if isinstance(auth, SessionError):
144
+ print(auth.message, file=sys.stderr)
147
145
  return 2
148
- api.organization_id = org["id"]
146
+ api, config = auth
147
+ try:
148
+ return _organization_set(api, config, settings, args.name, sys.stderr)
149
+ finally:
150
+ api.close()
151
+ session = open_session(settings=settings, token_store=token_store)
152
+ if isinstance(session, SessionError):
153
+ print(session.message, file=sys.stderr)
154
+ return 2
155
+ try:
149
156
  if args.command == "pull":
150
- return pull_path(api, args.remote, Path(args.dest), dry_run=args.dry_run, stderr=sys.stderr)
157
+ return pull_path(
158
+ session.api, args.remote, Path(args.dest), dry_run=args.dry_run, stderr=sys.stderr
159
+ ).exit_code
151
160
  if args.command == "export":
152
- return export_organization(api, Path(args.dest), dry_run=args.dry_run, stderr=sys.stderr)
161
+ return export_organization(
162
+ session.api, Path(args.dest), dry_run=args.dry_run, stderr=sys.stderr
163
+ ).exit_code
153
164
  if args.command == "push":
154
- return push_directory(api, Path(args.local_dir), args.remote, dry_run=args.dry_run, stderr=sys.stderr)
165
+ return push_directory(
166
+ session.api, Path(args.local_dir), args.remote, dry_run=args.dry_run, stderr=sys.stderr
167
+ ).exit_code
155
168
  parser.print_help(sys.stderr)
156
169
  return 2
157
170
  finally:
158
- api.close()
171
+ session.api.close()
159
172
 
160
173
 
161
174
  def _organization_list(api: ApiClient, config: ConfigStore, stdout) -> int:
@@ -181,13 +194,5 @@ def _organization_set(api: ApiClient, config: ConfigStore, settings: Settings, n
181
194
  return 0
182
195
 
183
196
 
184
- def _require_organization(api: ApiClient, config: ConfigStore, stderr):
185
- try:
186
- return select_organization(api.list_organizations(), config.organization_id())
187
- except OrgSelectionError as exc:
188
- print(str(exc), file=stderr)
189
- return None
190
-
191
-
192
197
  if __name__ == "__main__":
193
198
  raise SystemExit(main())
@@ -76,11 +76,21 @@ def local_relative(remote: str, relative_path: str) -> str:
76
76
 
77
77
 
78
78
  def destination_for(dest: Path, remote: str, relative_path: str, *, single_file: bool) -> Path:
79
- """Place a downloaded file under dest without keeping the ~/ prefix."""
79
+ """Place a downloaded file under dest without keeping the ~/ prefix.
80
+
81
+ Rejects ``..`` / absolute segments so a malicious manifest cannot escape ``dest``.
82
+ """
80
83
  name = local_relative(remote, relative_path)
81
84
  if single_file and not _dest_is_directory(dest):
82
85
  return dest
83
- return dest / name
86
+ rel = Path(name)
87
+ if rel.is_absolute() or any(part in {".", ".."} for part in rel.parts):
88
+ raise ValueError(f"Unsafe relative_path escapes destination: {relative_path!r}")
89
+ target = (dest / rel).resolve()
90
+ root = dest.resolve()
91
+ if root != target and root not in target.parents:
92
+ raise ValueError(f"Unsafe relative_path escapes destination: {relative_path!r}")
93
+ return dest / rel
84
94
 
85
95
 
86
96
  def _dest_is_directory(dest: Path) -> bool:
@@ -0,0 +1,13 @@
1
+ """Local MCP server for Proteworks (stdio). Domain tools register on the shared server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["create_server", "main"]
6
+
7
+
8
+ def __getattr__(name: str):
9
+ if name in {"create_server", "main"}:
10
+ from proteworks.mcp import server as _server
11
+
12
+ return getattr(_server, name)
13
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,32 @@
1
+ """Stable MCP tool error messages (no raw exception / URL leakage)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from proteworks.api import ApiError
8
+ from proteworks.redact import redact
9
+
10
+
11
+ def tool_error_message(exc: BaseException) -> str:
12
+ """Map an exception to a short, redacted user-facing message."""
13
+ if isinstance(exc, ApiError):
14
+ cleaned = redact(str(exc))
15
+ return cleaned if isinstance(cleaned, str) else "Request failed"
16
+ if isinstance(exc, (ValueError, RuntimeError, OSError, TypeError)):
17
+ cleaned = redact(str(exc))
18
+ if isinstance(cleaned, str) and cleaned.strip():
19
+ # Drop long path dumps / URL-like fragments.
20
+ text = cleaned.strip()
21
+ if len(text) > 240:
22
+ text = text[:237] + "..."
23
+ return text
24
+ return "Request failed"
25
+
26
+
27
+ def tool_failure(error: str, message: str, **extra: Any) -> dict[str, Any]:
28
+ """Build a standard MCP failure payload."""
29
+ payload: dict[str, Any] = {"ok": False, "error": error, "message": message}
30
+ payload.update(extra)
31
+ cleaned = redact(payload)
32
+ return cleaned if isinstance(cleaned, dict) else {"ok": False, "error": error, "message": message}
@@ -0,0 +1,47 @@
1
+ """Local path sandbox for MCP pull/push (workspace root containment)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ _ENV_ROOT = "PROTEWORKS_MCP_ROOT"
9
+
10
+
11
+ def mcp_workspace_root() -> Path:
12
+ """Return the allowlisted local root for MCP file transfers."""
13
+ raw = (os.environ.get(_ENV_ROOT) or "").strip()
14
+ if raw:
15
+ return Path(raw).expanduser().resolve()
16
+ return Path.cwd().resolve()
17
+
18
+
19
+ def resolve_under_workspace(user_path: str, *, root: Path | None = None) -> Path:
20
+ """Resolve a user path under the MCP workspace; reject escapes.
21
+
22
+ Relative paths are resolved against the workspace root. Absolute paths are
23
+ allowed only when they resolve inside the root.
24
+ """
25
+ workspace = (root or mcp_workspace_root()).resolve()
26
+ raw = str(user_path or "").strip()
27
+ if not raw:
28
+ raise ValueError("Path is required.")
29
+ candidate = Path(raw).expanduser()
30
+ if not candidate.is_absolute():
31
+ candidate = workspace / candidate
32
+ resolved = candidate.resolve()
33
+ if workspace != resolved and workspace not in resolved.parents:
34
+ raise ValueError(
35
+ f"Path escapes MCP workspace root ({workspace}). "
36
+ f"Set {_ENV_ROOT} or use a path under the workspace."
37
+ )
38
+ return resolved
39
+
40
+
41
+ def assert_relative_under(dest: Path, target: Path) -> Path:
42
+ """Ensure ``target`` resolves under ``dest`` (pull path containment)."""
43
+ root = dest.resolve()
44
+ resolved = target.resolve()
45
+ if root != resolved and root not in resolved.parents:
46
+ raise ValueError("Download path escapes the destination directory.")
47
+ return resolved