proteworks 0.1.2__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.2 → proteworks-0.1.4}/pyproject.toml +3 -1
  4. {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/api.py +135 -5
  5. proteworks-0.1.4/src/proteworks/browse.py +49 -0
  6. {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/cli.py +34 -29
  7. {proteworks-0.1.2 → 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.2 → proteworks-0.1.4}/src/proteworks/pull.py +96 -40
  19. {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/push.py +54 -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.2 → proteworks-0.1.4}/src/proteworks.egg-info/entry_points.txt +1 -0
  36. {proteworks-0.1.2 → 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.2 → proteworks-0.1.4}/tests/test_cli.py +558 -12
  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.2/PKG-INFO +0 -28
  46. proteworks-0.1.2/README.md +0 -17
  47. proteworks-0.1.2/src/proteworks.egg-info/PKG-INFO +0 -28
  48. proteworks-0.1.2/src/proteworks.egg-info/SOURCES.txt +0 -19
  49. {proteworks-0.1.2 → proteworks-0.1.4}/setup.cfg +0 -0
  50. {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/__init__.py +0 -0
  51. {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/auth.py +0 -0
  52. {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/config.py +0 -0
  53. {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/orgs.py +0 -0
  54. {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/redact.py +0 -0
  55. {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks.egg-info/dependency_links.txt +0 -0
  56. {proteworks-0.1.2 → 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.2"
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:
@@ -88,19 +117,21 @@ class ApiClient:
88
117
  params={"path": path, "offset": offset, "limit": limit},
89
118
  )
90
119
 
91
- def manifest_all(self, path: str) -> dict:
120
+ def manifest_all(self, path: str, *, on_list_progress=None, page_size: int = 500) -> dict:
92
121
  """Follow pages. Returns items plus root_folder_id from the first page."""
93
122
  offset = 0
94
123
  items: list[dict] = []
95
124
  root_folder_id = None
96
125
  total = None
97
126
  while True:
98
- page = self.manifest(path, offset)
127
+ page = self.manifest(path, offset, limit=page_size)
99
128
  root_folder_id = root_folder_id or page.get("root_folder_id")
100
129
  batch = list(page.get("items") or [])
101
130
  items.extend(batch)
102
131
  total = int(page.get("total") or 0)
103
132
  offset += len(batch)
133
+ if on_list_progress is not None:
134
+ on_list_progress(offset, total)
104
135
  if not batch or offset >= total:
105
136
  break
106
137
  return {"items": items, "total": total or 0, "root_folder_id": root_folder_id}
@@ -112,17 +143,19 @@ class ApiClient:
112
143
  params={"offset": offset, "limit": limit},
113
144
  )
114
145
 
115
- def export_all(self) -> dict:
146
+ def export_all(self, *, on_list_progress=None, page_size: int = 500) -> dict:
116
147
  """Follow export pages. Returns every item for the active organization."""
117
148
  offset = 0
118
149
  items: list[dict] = []
119
150
  total = None
120
151
  while True:
121
- page = self.export_page(offset)
152
+ page = self.export_page(offset, limit=page_size)
122
153
  batch = list(page.get("items") or [])
123
154
  items.extend(batch)
124
155
  total = int(page.get("total") or 0)
125
156
  offset += len(batch)
157
+ if on_list_progress is not None:
158
+ on_list_progress(offset, total)
126
159
  if not batch or offset >= total:
127
160
  break
128
161
  return {"items": items, "total": total or 0}
@@ -188,6 +221,7 @@ class ApiClient:
188
221
 
189
222
  def download_to(self, url: str, path) -> None:
190
223
  """Stream bytes to a file. The URL is not logged."""
224
+ assert_allowed_storage_url(url)
191
225
  with self._client.stream("GET", url) as response:
192
226
  response.raise_for_status()
193
227
  with open(path, "wb") as handle:
@@ -196,7 +230,103 @@ class ApiClient:
196
230
 
197
231
  def put_file(self, url: str, path, headers: Optional[dict] = None) -> None:
198
232
  """Upload a file. The URL is not logged."""
233
+ assert_allowed_storage_url(url)
199
234
  with open(path, "rb") as handle:
200
235
  response = self._client.put(url, content=handle, headers=headers or {})
201
236
  if response.status_code >= 400:
202
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}