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.
- proteworks-0.1.4/PKG-INFO +116 -0
- proteworks-0.1.4/README.md +102 -0
- {proteworks-0.1.3 → proteworks-0.1.4}/pyproject.toml +3 -1
- {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/api.py +127 -1
- proteworks-0.1.4/src/proteworks/browse.py +49 -0
- {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/cli.py +34 -29
- {proteworks-0.1.3 → 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.3 → proteworks-0.1.4}/src/proteworks/pull.py +88 -41
- {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/push.py +51 -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.3 → proteworks-0.1.4}/src/proteworks.egg-info/entry_points.txt +1 -0
- {proteworks-0.1.3 → 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.3 → proteworks-0.1.4}/tests/test_cli.py +519 -5
- 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.3/PKG-INFO +0 -28
- proteworks-0.1.3/README.md +0 -17
- proteworks-0.1.3/src/proteworks.egg-info/PKG-INFO +0 -28
- proteworks-0.1.3/src/proteworks.egg-info/SOURCES.txt +0 -19
- {proteworks-0.1.3 → proteworks-0.1.4}/setup.cfg +0 -0
- {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/__init__.py +0 -0
- {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/auth.py +0 -0
- {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/config.py +0 -0
- {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/orgs.py +0 -0
- {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks/redact.py +0 -0
- {proteworks-0.1.3 → proteworks-0.1.4}/src/proteworks.egg-info/dependency_links.txt +0 -0
- {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
|
+
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
|
-
|
|
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}
|
|
@@ -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
|