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.
- proteworks-0.1.4/PKG-INFO +116 -0
- proteworks-0.1.4/README.md +102 -0
- {proteworks-0.1.2 → proteworks-0.1.4}/pyproject.toml +3 -1
- {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/api.py +135 -5
- proteworks-0.1.4/src/proteworks/browse.py +49 -0
- {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/cli.py +34 -29
- {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/index.py +12 -2
- proteworks-0.1.4/src/proteworks/mcp/__init__.py +13 -0
- proteworks-0.1.4/src/proteworks/mcp/errors.py +32 -0
- proteworks-0.1.4/src/proteworks/mcp/paths.py +47 -0
- proteworks-0.1.4/src/proteworks/mcp/profile.py +30 -0
- proteworks-0.1.4/src/proteworks/mcp/runtime.py +47 -0
- proteworks-0.1.4/src/proteworks/mcp/server.py +64 -0
- proteworks-0.1.4/src/proteworks/mcp/tools_files.py +131 -0
- proteworks-0.1.4/src/proteworks/mcp/tools_orgs.py +139 -0
- proteworks-0.1.4/src/proteworks/mcp/tools_studio.py +393 -0
- proteworks-0.1.4/src/proteworks/mcp/tools_workflows.py +343 -0
- {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/pull.py +96 -40
- {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/push.py +54 -25
- proteworks-0.1.4/src/proteworks/results.py +42 -0
- proteworks-0.1.4/src/proteworks/session.py +77 -0
- proteworks-0.1.4/src/proteworks/studio/__init__.py +16 -0
- proteworks-0.1.4/src/proteworks/studio/common.py +213 -0
- proteworks-0.1.4/src/proteworks/studio/kinds/__init__.py +25 -0
- proteworks-0.1.4/src/proteworks/studio/kinds/base.py +98 -0
- proteworks-0.1.4/src/proteworks/studio/kinds/proteinmpnn.py +214 -0
- proteworks-0.1.4/src/proteworks/studio/kinds/refolding.py +120 -0
- proteworks-0.1.4/src/proteworks/studio/kinds/rfd3.py +93 -0
- proteworks-0.1.4/src/proteworks/studio/kinds/structure_prediction.py +124 -0
- proteworks-0.1.4/src/proteworks/studio/registry.py +49 -0
- proteworks-0.1.4/src/proteworks/studio_proteinmpnn.py +37 -0
- proteworks-0.1.4/src/proteworks/workflows.py +704 -0
- proteworks-0.1.4/src/proteworks.egg-info/PKG-INFO +116 -0
- proteworks-0.1.4/src/proteworks.egg-info/SOURCES.txt +50 -0
- {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks.egg-info/entry_points.txt +1 -0
- {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks.egg-info/requires.txt +4 -0
- proteworks-0.1.4/tests/test_browse.py +49 -0
- {proteworks-0.1.2 → proteworks-0.1.4}/tests/test_cli.py +558 -12
- proteworks-0.1.4/tests/test_mcp_adversarial.py +142 -0
- proteworks-0.1.4/tests/test_mcp_files.py +138 -0
- proteworks-0.1.4/tests/test_mcp_studio.py +290 -0
- proteworks-0.1.4/tests/test_mcp_workflows.py +436 -0
- proteworks-0.1.4/tests/test_session.py +45 -0
- proteworks-0.1.4/tests/test_transfer_result.py +123 -0
- proteworks-0.1.2/PKG-INFO +0 -28
- proteworks-0.1.2/README.md +0 -17
- proteworks-0.1.2/src/proteworks.egg-info/PKG-INFO +0 -28
- proteworks-0.1.2/src/proteworks.egg-info/SOURCES.txt +0 -19
- {proteworks-0.1.2 → proteworks-0.1.4}/setup.cfg +0 -0
- {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/__init__.py +0 -0
- {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/auth.py +0 -0
- {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/config.py +0 -0
- {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/orgs.py +0 -0
- {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks/redact.py +0 -0
- {proteworks-0.1.2 → proteworks-0.1.4}/src/proteworks.egg-info/dependency_links.txt +0 -0
- {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.
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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}
|