assemblix-mcp 0.1.0__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.
- assemblix_mcp-0.1.0/.gitignore +16 -0
- assemblix_mcp-0.1.0/LICENSE +21 -0
- assemblix_mcp-0.1.0/PKG-INFO +95 -0
- assemblix_mcp-0.1.0/README.md +70 -0
- assemblix_mcp-0.1.0/pyproject.toml +48 -0
- assemblix_mcp-0.1.0/src/assemblix_mcp/__init__.py +3 -0
- assemblix_mcp-0.1.0/src/assemblix_mcp/client.py +192 -0
- assemblix_mcp-0.1.0/src/assemblix_mcp/config.py +38 -0
- assemblix_mcp-0.1.0/src/assemblix_mcp/resources/__init__.py +50 -0
- assemblix_mcp-0.1.0/src/assemblix_mcp/resources/examples/branching.json +63 -0
- assemblix_mcp-0.1.0/src/assemblix_mcp/resources/examples/minimal.json +36 -0
- assemblix_mcp-0.1.0/src/assemblix_mcp/server.py +42 -0
- assemblix_mcp-0.1.0/src/assemblix_mcp/tools/__init__.py +1 -0
- assemblix_mcp-0.1.0/src/assemblix_mcp/tools/executions.py +115 -0
- assemblix_mcp-0.1.0/src/assemblix_mcp/tools/workflows.py +76 -0
- assemblix_mcp-0.1.0/tests/test_client_executions.py +49 -0
- assemblix_mcp-0.1.0/tests/test_client_workflows.py +54 -0
- assemblix_mcp-0.1.0/tests/test_config.py +22 -0
- assemblix_mcp-0.1.0/tests/test_execution_tools.py +81 -0
- assemblix_mcp-0.1.0/tests/test_resources.py +39 -0
- assemblix_mcp-0.1.0/tests/test_server.py +71 -0
- assemblix_mcp-0.1.0/tests/test_workflow_tools.py +42 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 the Assemblix authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: assemblix-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Assemblix — author, run & inspect workflows over a project API key
|
|
5
|
+
Project-URL: Homepage, https://github.com/nmamizerov/assemblix-aitools
|
|
6
|
+
Project-URL: Repository, https://github.com/nmamizerov/assemblix-aitools
|
|
7
|
+
Project-URL: Issues, https://github.com/nmamizerov/assemblix-aitools/issues
|
|
8
|
+
Author-email: nmamizerov <nmamizerov@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agents,assemblix,llm,mcp,model-context-protocol,workflows
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: fastmcp>=3
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Requires-Dist: pydantic>=2
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# assemblix-mcp
|
|
27
|
+
|
|
28
|
+
MCP server for [Assemblix](https://app.assmblx.com). Author, run & inspect your
|
|
29
|
+
project's workflows from any MCP client using a single project API key (`sk_...`).
|
|
30
|
+
`projectId` is never needed — the key defines the project.
|
|
31
|
+
|
|
32
|
+
Get your `sk_` key from the project's **API keys** page in the Assemblix UI.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
### Hosted (easiest — nothing to install)
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
claude mcp add --transport http assemblix https://mcp.assmblx.com \
|
|
40
|
+
--header "Authorization: Bearer sk_your_key"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Local via uvx (privacy / self-host)
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
claude mcp add assemblix \
|
|
47
|
+
--env ASSEMBLIX_API_KEY=sk_your_key \
|
|
48
|
+
--env ASSEMBLIX_API_URL=https://app.assmblx.com \
|
|
49
|
+
-- uvx assemblix-mcp
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Claude Desktop / Cursor (JSON config)
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"mcpServers": {
|
|
57
|
+
"assemblix": {
|
|
58
|
+
"command": "uvx",
|
|
59
|
+
"args": ["assemblix-mcp"],
|
|
60
|
+
"env": {
|
|
61
|
+
"ASSEMBLIX_API_KEY": "sk_your_key",
|
|
62
|
+
"ASSEMBLIX_API_URL": "https://app.assmblx.com"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
For the hosted server, use your client's "add custom connector → URL + header" flow.
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
| Env var | Default | Notes |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| `ASSEMBLIX_API_URL` | `https://app.assmblx.com` | Base URL of your Assemblix instance |
|
|
76
|
+
| `ASSEMBLIX_API_KEY` | — | Project `sk_` key. Required for stdio; for hosted HTTP the `Authorization` header is used instead |
|
|
77
|
+
| `ASSEMBLIX_MCP_TRANSPORT` | `stdio` | `stdio` or `http` |
|
|
78
|
+
| `ASSEMBLIX_MCP_HOST` / `ASSEMBLIX_MCP_PORT` | `0.0.0.0` / `8000` | HTTP transport bind |
|
|
79
|
+
|
|
80
|
+
## Tools
|
|
81
|
+
|
|
82
|
+
**Authoring:** `list_node_types`, `list_workflows`, `get_workflow`, `create_workflow`,
|
|
83
|
+
`update_workflow`, `publish_workflow`
|
|
84
|
+
**Run & inspect:** `run_workflow`, `run_workflow_and_wait`, `get_execution`,
|
|
85
|
+
`list_executions`, `get_execution_detail`, `list_in_flight`
|
|
86
|
+
|
|
87
|
+
**Resources:** `assemblix://examples/minimal`, `assemblix://examples/branching`
|
|
88
|
+
(example workflow JSON). **Prompt:** `author_workflow` (authoring guide).
|
|
89
|
+
|
|
90
|
+
Lifecycle: `create_workflow → update_workflow(nodes, edges) → publish_workflow →
|
|
91
|
+
run_workflow`. Runs always execute the **published** snapshot. AGENT nodes need a
|
|
92
|
+
provider credential configured in the Assemblix UI.
|
|
93
|
+
|
|
94
|
+
All tools operate on the single project your API key is scoped to; `projectId` is
|
|
95
|
+
never a parameter.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# assemblix-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [Assemblix](https://app.assmblx.com). Author, run & inspect your
|
|
4
|
+
project's workflows from any MCP client using a single project API key (`sk_...`).
|
|
5
|
+
`projectId` is never needed — the key defines the project.
|
|
6
|
+
|
|
7
|
+
Get your `sk_` key from the project's **API keys** page in the Assemblix UI.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
### Hosted (easiest — nothing to install)
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
claude mcp add --transport http assemblix https://mcp.assmblx.com \
|
|
15
|
+
--header "Authorization: Bearer sk_your_key"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### Local via uvx (privacy / self-host)
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
claude mcp add assemblix \
|
|
22
|
+
--env ASSEMBLIX_API_KEY=sk_your_key \
|
|
23
|
+
--env ASSEMBLIX_API_URL=https://app.assmblx.com \
|
|
24
|
+
-- uvx assemblix-mcp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Claude Desktop / Cursor (JSON config)
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"assemblix": {
|
|
33
|
+
"command": "uvx",
|
|
34
|
+
"args": ["assemblix-mcp"],
|
|
35
|
+
"env": {
|
|
36
|
+
"ASSEMBLIX_API_KEY": "sk_your_key",
|
|
37
|
+
"ASSEMBLIX_API_URL": "https://app.assmblx.com"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For the hosted server, use your client's "add custom connector → URL + header" flow.
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
| Env var | Default | Notes |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| `ASSEMBLIX_API_URL` | `https://app.assmblx.com` | Base URL of your Assemblix instance |
|
|
51
|
+
| `ASSEMBLIX_API_KEY` | — | Project `sk_` key. Required for stdio; for hosted HTTP the `Authorization` header is used instead |
|
|
52
|
+
| `ASSEMBLIX_MCP_TRANSPORT` | `stdio` | `stdio` or `http` |
|
|
53
|
+
| `ASSEMBLIX_MCP_HOST` / `ASSEMBLIX_MCP_PORT` | `0.0.0.0` / `8000` | HTTP transport bind |
|
|
54
|
+
|
|
55
|
+
## Tools
|
|
56
|
+
|
|
57
|
+
**Authoring:** `list_node_types`, `list_workflows`, `get_workflow`, `create_workflow`,
|
|
58
|
+
`update_workflow`, `publish_workflow`
|
|
59
|
+
**Run & inspect:** `run_workflow`, `run_workflow_and_wait`, `get_execution`,
|
|
60
|
+
`list_executions`, `get_execution_detail`, `list_in_flight`
|
|
61
|
+
|
|
62
|
+
**Resources:** `assemblix://examples/minimal`, `assemblix://examples/branching`
|
|
63
|
+
(example workflow JSON). **Prompt:** `author_workflow` (authoring guide).
|
|
64
|
+
|
|
65
|
+
Lifecycle: `create_workflow → update_workflow(nodes, edges) → publish_workflow →
|
|
66
|
+
run_workflow`. Runs always execute the **published** snapshot. AGENT nodes need a
|
|
67
|
+
provider credential configured in the Assemblix UI.
|
|
68
|
+
|
|
69
|
+
All tools operate on the single project your API key is scoped to; `projectId` is
|
|
70
|
+
never a parameter.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "assemblix-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server for Assemblix — author, run & inspect workflows over a project API key"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "nmamizerov", email = "nmamizerov@gmail.com" }]
|
|
9
|
+
keywords = ["assemblix", "mcp", "model-context-protocol", "workflows", "llm", "ai-agents"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Topic :: Software Development :: Libraries",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"fastmcp>=3",
|
|
22
|
+
"httpx>=0.27",
|
|
23
|
+
"pydantic>=2",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/nmamizerov/assemblix-aitools"
|
|
28
|
+
Repository = "https://github.com/nmamizerov/assemblix-aitools"
|
|
29
|
+
Issues = "https://github.com/nmamizerov/assemblix-aitools/issues"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
assemblix-mcp = "assemblix_mcp.server:main"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/assemblix_mcp"]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.sdist]
|
|
42
|
+
exclude = ["uv.lock"]
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "respx>=0.21"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Thin async HTTP client over the Assemblix REST API.
|
|
2
|
+
|
|
3
|
+
Wire contract: query params are snake_case; request bodies are camelCase;
|
|
4
|
+
responses are camelCase. ``project_id`` is injected only where the endpoint
|
|
5
|
+
expects it (workflows list query + create body). Id-addressed and key-scoped
|
|
6
|
+
endpoints do not need it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AssemblixAPIError(Exception):
|
|
17
|
+
def __init__(self, status_code: int, detail: str) -> None:
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.detail = detail
|
|
20
|
+
super().__init__(f"{status_code}: {detail}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AssemblixClient:
|
|
24
|
+
def __init__(self, base_url: str, api_key: str, project_id: str) -> None:
|
|
25
|
+
self._base = base_url.rstrip("/")
|
|
26
|
+
self._project_id = project_id
|
|
27
|
+
self._headers = {"Authorization": f"Bearer {api_key}"}
|
|
28
|
+
|
|
29
|
+
async def _request(
|
|
30
|
+
self,
|
|
31
|
+
method: str,
|
|
32
|
+
path: str,
|
|
33
|
+
*,
|
|
34
|
+
params: dict | None = None,
|
|
35
|
+
json: dict | None = None,
|
|
36
|
+
) -> Any:
|
|
37
|
+
async with httpx.AsyncClient(base_url=self._base, headers=self._headers) as http:
|
|
38
|
+
resp = await http.request(method, path, params=params, json=json)
|
|
39
|
+
if resp.status_code >= 400:
|
|
40
|
+
raise AssemblixAPIError(resp.status_code, _extract_detail(resp))
|
|
41
|
+
if resp.status_code == 204 or not resp.content:
|
|
42
|
+
return None
|
|
43
|
+
return resp.json()
|
|
44
|
+
|
|
45
|
+
# --- nodes ---
|
|
46
|
+
async def list_node_types(self) -> Any:
|
|
47
|
+
return await self._request("GET", "/api/nodes")
|
|
48
|
+
|
|
49
|
+
# --- workflows ---
|
|
50
|
+
async def list_workflows(
|
|
51
|
+
self,
|
|
52
|
+
is_active: bool | None = None,
|
|
53
|
+
is_published: bool | None = None,
|
|
54
|
+
is_template: bool | None = None,
|
|
55
|
+
) -> Any:
|
|
56
|
+
params = _clean(
|
|
57
|
+
{
|
|
58
|
+
"project_id": self._project_id,
|
|
59
|
+
"is_active": is_active,
|
|
60
|
+
"is_published": is_published,
|
|
61
|
+
"is_template": is_template,
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
return await self._request("GET", "/api/workflows/", params=params)
|
|
65
|
+
|
|
66
|
+
async def get_workflow(self, workflow_id: str) -> Any:
|
|
67
|
+
return await self._request("GET", f"/api/workflows/{workflow_id}")
|
|
68
|
+
|
|
69
|
+
async def create_workflow(
|
|
70
|
+
self,
|
|
71
|
+
name: str | None = None,
|
|
72
|
+
description: str | None = None,
|
|
73
|
+
nodes: list | None = None,
|
|
74
|
+
edges: list | None = None,
|
|
75
|
+
state: list | None = None,
|
|
76
|
+
) -> Any:
|
|
77
|
+
body = _clean(
|
|
78
|
+
{
|
|
79
|
+
"projectId": self._project_id,
|
|
80
|
+
"name": name,
|
|
81
|
+
"description": description,
|
|
82
|
+
"nodes": nodes,
|
|
83
|
+
"edges": edges,
|
|
84
|
+
"state": state,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
return await self._request("POST", "/api/workflows/", json=body)
|
|
88
|
+
|
|
89
|
+
async def update_workflow(
|
|
90
|
+
self,
|
|
91
|
+
workflow_id: str,
|
|
92
|
+
name: str | None = None,
|
|
93
|
+
description: str | None = None,
|
|
94
|
+
nodes: list | None = None,
|
|
95
|
+
edges: list | None = None,
|
|
96
|
+
state: list | None = None,
|
|
97
|
+
) -> Any:
|
|
98
|
+
body = _clean(
|
|
99
|
+
{
|
|
100
|
+
"name": name,
|
|
101
|
+
"description": description,
|
|
102
|
+
"nodes": nodes,
|
|
103
|
+
"edges": edges,
|
|
104
|
+
"state": state,
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
return await self._request("PATCH", f"/api/workflows/{workflow_id}", json=body)
|
|
108
|
+
|
|
109
|
+
async def publish_workflow(self, workflow_id: str) -> Any:
|
|
110
|
+
return await self._request("POST", f"/api/workflows/{workflow_id}/publish")
|
|
111
|
+
|
|
112
|
+
# --- execution ---
|
|
113
|
+
async def execute_workflow(
|
|
114
|
+
self,
|
|
115
|
+
workflow_id: str,
|
|
116
|
+
input: dict,
|
|
117
|
+
task: bool = False,
|
|
118
|
+
state: dict | None = None,
|
|
119
|
+
project_state: dict | None = None,
|
|
120
|
+
client_id: str | None = None,
|
|
121
|
+
metadata: dict | None = None,
|
|
122
|
+
) -> Any:
|
|
123
|
+
body = _clean(
|
|
124
|
+
{
|
|
125
|
+
"input": input,
|
|
126
|
+
"task": task,
|
|
127
|
+
"state": state,
|
|
128
|
+
"projectState": project_state,
|
|
129
|
+
"clientId": client_id,
|
|
130
|
+
"metadata": metadata,
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
return await self._request(
|
|
134
|
+
"POST", f"/api/workflows/{workflow_id}/execute", json=body
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
async def get_task_result(self, execution_id: str) -> Any:
|
|
138
|
+
return await self._request("GET", f"/api/executions/task/{execution_id}")
|
|
139
|
+
|
|
140
|
+
async def list_executions(
|
|
141
|
+
self,
|
|
142
|
+
workflow_id: str | None = None,
|
|
143
|
+
status: str | None = None,
|
|
144
|
+
date_from: str | None = None,
|
|
145
|
+
date_to: str | None = None,
|
|
146
|
+
page: int = 1,
|
|
147
|
+
limit: int = 50,
|
|
148
|
+
) -> Any:
|
|
149
|
+
params = _clean(
|
|
150
|
+
{
|
|
151
|
+
"project_id": self._project_id,
|
|
152
|
+
"workflow_id": workflow_id,
|
|
153
|
+
"status": status,
|
|
154
|
+
"date_from": date_from,
|
|
155
|
+
"date_to": date_to,
|
|
156
|
+
"page": page,
|
|
157
|
+
"limit": limit,
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
return await self._request("GET", "/api/executions/", params=params)
|
|
161
|
+
|
|
162
|
+
async def get_execution_detail(self, execution_id: str) -> Any:
|
|
163
|
+
return await self._request("GET", f"/api/executions/{execution_id}")
|
|
164
|
+
|
|
165
|
+
async def list_in_flight(self) -> Any:
|
|
166
|
+
return await self._request("GET", "/api/executions/in-flight")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def fetch_project_id(base_url: str, api_key: str) -> str:
|
|
170
|
+
"""Resolve the key's project via the whoami endpoint."""
|
|
171
|
+
async with httpx.AsyncClient(
|
|
172
|
+
base_url=base_url.rstrip("/"),
|
|
173
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
174
|
+
) as http:
|
|
175
|
+
resp = await http.get("/api/api-keys/whoami")
|
|
176
|
+
if resp.status_code >= 400:
|
|
177
|
+
raise AssemblixAPIError(resp.status_code, _extract_detail(resp))
|
|
178
|
+
return str(resp.json()["projectId"])
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _clean(d: dict) -> dict:
|
|
182
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _extract_detail(resp: httpx.Response) -> str:
|
|
186
|
+
try:
|
|
187
|
+
body = resp.json()
|
|
188
|
+
if isinstance(body, dict) and "detail" in body:
|
|
189
|
+
return str(body["detail"])
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
return resp.text or resp.reason_phrase
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Configuration and per-request API key resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Settings(BaseModel):
|
|
11
|
+
api_url: str
|
|
12
|
+
api_key: str | None
|
|
13
|
+
transport: str
|
|
14
|
+
host: str
|
|
15
|
+
port: int
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_env(cls) -> "Settings":
|
|
19
|
+
return cls(
|
|
20
|
+
api_url=os.environ.get("ASSEMBLIX_API_URL", "https://app.assmblx.com"),
|
|
21
|
+
api_key=os.environ.get("ASSEMBLIX_API_KEY"),
|
|
22
|
+
transport=os.environ.get("ASSEMBLIX_MCP_TRANSPORT", "stdio"),
|
|
23
|
+
host=os.environ.get("ASSEMBLIX_MCP_HOST", "0.0.0.0"),
|
|
24
|
+
port=int(os.environ.get("ASSEMBLIX_MCP_PORT", "8000")),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve_api_key(settings: Settings, header_value: str | None) -> str:
|
|
29
|
+
"""Return the bearer token, preferring an incoming Authorization header."""
|
|
30
|
+
if header_value:
|
|
31
|
+
token = header_value.removeprefix("Bearer ").strip()
|
|
32
|
+
if token:
|
|
33
|
+
return token
|
|
34
|
+
if settings.api_key:
|
|
35
|
+
return settings.api_key
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"No API key: set ASSEMBLIX_API_KEY or send an Authorization: Bearer header."
|
|
38
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Bundled example workflows + authoring prompt registered on the MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import resources
|
|
6
|
+
|
|
7
|
+
from fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
_EXAMPLES = resources.files("assemblix_mcp.resources") / "examples"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load(name: str) -> str:
|
|
13
|
+
return (_EXAMPLES / name).read_text(encoding="utf-8")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register_resources(mcp: FastMCP) -> None:
|
|
17
|
+
@mcp.resource("assemblix://examples/minimal")
|
|
18
|
+
def minimal_example() -> str:
|
|
19
|
+
"""A runnable START -> AGENT -> END workflow (create -> update ->
|
|
20
|
+
publish -> run). Shows the START node shape, which is not in
|
|
21
|
+
list_node_types."""
|
|
22
|
+
return _load("minimal.json")
|
|
23
|
+
|
|
24
|
+
@mcp.resource("assemblix://examples/branching")
|
|
25
|
+
def branching_example() -> str:
|
|
26
|
+
"""A START -> CONDITION -> two AGENT branches -> END workflow. Shows how
|
|
27
|
+
condition branch edges use sourceHandle 'source_<conditionId>_<index>'."""
|
|
28
|
+
return _load("branching.json")
|
|
29
|
+
|
|
30
|
+
@mcp.prompt
|
|
31
|
+
def author_workflow() -> str:
|
|
32
|
+
"""Guidance for authoring an Assemblix workflow via these tools."""
|
|
33
|
+
return (
|
|
34
|
+
"To author and run an Assemblix workflow:\n"
|
|
35
|
+
"1. Call list_node_types to see AGENT/CONDITION/HTTP_REQUEST/"
|
|
36
|
+
"SET_VARIABLE/DELAY/END config schemas. START is implicit and NOT "
|
|
37
|
+
"listed — copy its shape from the example resources.\n"
|
|
38
|
+
"2. Read assemblix://examples/minimal (and /branching) for the exact "
|
|
39
|
+
"node/edge JSON shape: node = {id, type, position:{x,y}, config}, "
|
|
40
|
+
"edge = {id, source, target, sourceHandle?}. CONDITION branch edges "
|
|
41
|
+
"use sourceHandle 'source_<conditionNodeId>_<index>'.\n"
|
|
42
|
+
"3. create_workflow -> update_workflow(nodes, edges, state) -> "
|
|
43
|
+
"publish_workflow. Runs always execute the PUBLISHED snapshot, so "
|
|
44
|
+
"publish after every change you want to run.\n"
|
|
45
|
+
"4. run_workflow (async, poll get_execution) or run_workflow_and_wait. "
|
|
46
|
+
"input usually is {\"message\": \"...\"}.\n"
|
|
47
|
+
"5. Inspect with get_execution_detail / list_executions. AGENT nodes "
|
|
48
|
+
"need a provider credential configured in the Assemblix UI; if missing, "
|
|
49
|
+
"the run fails with a clear error."
|
|
50
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Branching agent",
|
|
3
|
+
"description": "START -> CONDITION -> two AGENT branches -> END. The condition routes by index; branch edges use sourceHandle source_<conditionId>_<index>.",
|
|
4
|
+
"nodes": [
|
|
5
|
+
{
|
|
6
|
+
"id": "start-1",
|
|
7
|
+
"type": "start",
|
|
8
|
+
"position": { "x": 0, "y": 0 },
|
|
9
|
+
"config": { "firstPhrase": null, "acceptVoice": false }
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "cond-1",
|
|
13
|
+
"type": "condition",
|
|
14
|
+
"position": { "x": 320, "y": 0 },
|
|
15
|
+
"config": {
|
|
16
|
+
"conditions": [
|
|
17
|
+
{ "name": "mentions bug", "expression": "input.message.contains('bug')" },
|
|
18
|
+
{ "name": "default", "expression": "true" }
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "agent-bug",
|
|
24
|
+
"type": "agent",
|
|
25
|
+
"position": { "x": 640, "y": -120 },
|
|
26
|
+
"config": {
|
|
27
|
+
"name": "Bug helper",
|
|
28
|
+
"provider": "openai",
|
|
29
|
+
"model": "gpt-4o-mini",
|
|
30
|
+
"instructions": [
|
|
31
|
+
{ "role": "system", "content": "The user reported a bug. Ask one clarifying question." }
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "agent-default",
|
|
37
|
+
"type": "agent",
|
|
38
|
+
"position": { "x": 640, "y": 120 },
|
|
39
|
+
"config": {
|
|
40
|
+
"name": "General helper",
|
|
41
|
+
"provider": "openai",
|
|
42
|
+
"model": "gpt-4o-mini",
|
|
43
|
+
"instructions": [
|
|
44
|
+
{ "role": "system", "content": "Answer the user's message helpfully." }
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"id": "end-1",
|
|
50
|
+
"type": "end",
|
|
51
|
+
"position": { "x": 960, "y": 0 },
|
|
52
|
+
"config": { "name": "Done", "outputMode": "last_agent" }
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"edges": [
|
|
56
|
+
{ "id": "e1", "source": "start-1", "target": "cond-1" },
|
|
57
|
+
{ "id": "e2", "source": "cond-1", "target": "agent-bug", "sourceHandle": "source_cond-1_0" },
|
|
58
|
+
{ "id": "e3", "source": "cond-1", "target": "agent-default", "sourceHandle": "source_cond-1_1" },
|
|
59
|
+
{ "id": "e4", "source": "agent-bug", "target": "end-1" },
|
|
60
|
+
{ "id": "e5", "source": "agent-default", "target": "end-1" }
|
|
61
|
+
],
|
|
62
|
+
"state": []
|
|
63
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Minimal chat agent",
|
|
3
|
+
"description": "START -> AGENT -> END. Replies to input.message with an LLM.",
|
|
4
|
+
"nodes": [
|
|
5
|
+
{
|
|
6
|
+
"id": "start-1",
|
|
7
|
+
"type": "start",
|
|
8
|
+
"position": { "x": 0, "y": 0 },
|
|
9
|
+
"config": { "firstPhrase": null, "acceptVoice": false }
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "agent-1",
|
|
13
|
+
"type": "agent",
|
|
14
|
+
"position": { "x": 320, "y": 0 },
|
|
15
|
+
"config": {
|
|
16
|
+
"name": "Assistant",
|
|
17
|
+
"provider": "openai",
|
|
18
|
+
"model": "gpt-4o-mini",
|
|
19
|
+
"instructions": [
|
|
20
|
+
{ "role": "system", "content": "You are a helpful assistant. Answer the user's message." }
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "end-1",
|
|
26
|
+
"type": "end",
|
|
27
|
+
"position": { "x": 640, "y": 0 },
|
|
28
|
+
"config": { "name": "Done", "outputMode": "last_agent" }
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"edges": [
|
|
32
|
+
{ "id": "e1", "source": "start-1", "target": "agent-1" },
|
|
33
|
+
{ "id": "e2", "source": "agent-1", "target": "end-1" }
|
|
34
|
+
],
|
|
35
|
+
"state": []
|
|
36
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""FastMCP server assembly and entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastmcp import FastMCP
|
|
6
|
+
from fastmcp.server.dependencies import get_http_headers
|
|
7
|
+
|
|
8
|
+
from assemblix_mcp.client import AssemblixClient, fetch_project_id
|
|
9
|
+
from assemblix_mcp.config import Settings, resolve_api_key
|
|
10
|
+
from assemblix_mcp.resources import register_resources
|
|
11
|
+
from assemblix_mcp.tools.executions import register_execution_tools
|
|
12
|
+
from assemblix_mcp.tools.workflows import register_workflow_tools
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_server(settings: Settings) -> FastMCP:
|
|
16
|
+
mcp = FastMCP("assemblix")
|
|
17
|
+
|
|
18
|
+
async def get_client() -> AssemblixClient:
|
|
19
|
+
headers = get_http_headers(include={"authorization"}) or {}
|
|
20
|
+
api_key = resolve_api_key(settings, headers.get("authorization"))
|
|
21
|
+
project_id = await fetch_project_id(settings.api_url, api_key)
|
|
22
|
+
return AssemblixClient(
|
|
23
|
+
base_url=settings.api_url, api_key=api_key, project_id=project_id
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
register_workflow_tools(mcp, get_client)
|
|
27
|
+
register_execution_tools(mcp, get_client)
|
|
28
|
+
register_resources(mcp)
|
|
29
|
+
return mcp
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main() -> None:
|
|
33
|
+
settings = Settings.from_env()
|
|
34
|
+
mcp = build_server(settings)
|
|
35
|
+
if settings.transport == "http":
|
|
36
|
+
mcp.run(transport="http", host=settings.host, port=settings.port)
|
|
37
|
+
else:
|
|
38
|
+
mcp.run()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP tool registration."""
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Run & inspect tools. project_id is implicit in the API key."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastmcp import FastMCP
|
|
10
|
+
|
|
11
|
+
from assemblix_mcp.tools.workflows import GetClient
|
|
12
|
+
|
|
13
|
+
_PENDING = {"running", "queued", "pending"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_terminal(payload: Any) -> bool:
|
|
17
|
+
status = str((payload or {}).get("status", "")).lower()
|
|
18
|
+
return status not in _PENDING
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register_execution_tools(mcp: FastMCP, get_client: GetClient) -> None:
|
|
22
|
+
@mcp.tool
|
|
23
|
+
async def run_workflow(
|
|
24
|
+
workflow_id: str,
|
|
25
|
+
input: dict,
|
|
26
|
+
state: dict | None = None,
|
|
27
|
+
project_state: dict | None = None,
|
|
28
|
+
client_id: str | None = None,
|
|
29
|
+
metadata: dict | None = None,
|
|
30
|
+
) -> Any:
|
|
31
|
+
"""Start the published workflow asynchronously. Returns immediately with
|
|
32
|
+
an executionId; poll it with get_execution. `input` typically holds a
|
|
33
|
+
'message' field. Publish the workflow first."""
|
|
34
|
+
client = await get_client()
|
|
35
|
+
return await client.execute_workflow(
|
|
36
|
+
workflow_id,
|
|
37
|
+
input=input,
|
|
38
|
+
task=True,
|
|
39
|
+
state=state,
|
|
40
|
+
project_state=project_state,
|
|
41
|
+
client_id=client_id,
|
|
42
|
+
metadata=metadata,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@mcp.tool
|
|
46
|
+
async def run_workflow_and_wait(
|
|
47
|
+
workflow_id: str,
|
|
48
|
+
input: dict,
|
|
49
|
+
state: dict | None = None,
|
|
50
|
+
project_state: dict | None = None,
|
|
51
|
+
timeout_seconds: float = 120.0,
|
|
52
|
+
poll_interval: float = 2.0,
|
|
53
|
+
) -> Any:
|
|
54
|
+
"""Start the published workflow and poll until it finishes or the timeout
|
|
55
|
+
elapses. Returns the final result — including an error payload on failure
|
|
56
|
+
(does not raise). On timeout returns the last status with timedOut=true."""
|
|
57
|
+
client = await get_client()
|
|
58
|
+
started = await client.execute_workflow(
|
|
59
|
+
workflow_id,
|
|
60
|
+
input=input,
|
|
61
|
+
task=True,
|
|
62
|
+
state=state,
|
|
63
|
+
project_state=project_state,
|
|
64
|
+
)
|
|
65
|
+
if _is_terminal(started):
|
|
66
|
+
return started
|
|
67
|
+
execution_id = str(started["executionId"])
|
|
68
|
+
deadline = time.monotonic() + timeout_seconds
|
|
69
|
+
while time.monotonic() < deadline:
|
|
70
|
+
await asyncio.sleep(poll_interval)
|
|
71
|
+
result = await client.get_task_result(execution_id)
|
|
72
|
+
if _is_terminal(result):
|
|
73
|
+
return result
|
|
74
|
+
return {"executionId": execution_id, "status": "running", "timedOut": True}
|
|
75
|
+
|
|
76
|
+
@mcp.tool
|
|
77
|
+
async def get_execution(execution_id: str) -> Any:
|
|
78
|
+
"""Get the status/result of one run (poll after run_workflow). Returns
|
|
79
|
+
status 'running' while in progress, else the completed or error payload."""
|
|
80
|
+
client = await get_client()
|
|
81
|
+
return await client.get_task_result(execution_id)
|
|
82
|
+
|
|
83
|
+
@mcp.tool
|
|
84
|
+
async def list_executions(
|
|
85
|
+
workflow_id: str | None = None,
|
|
86
|
+
status: str | None = None,
|
|
87
|
+
date_from: str | None = None,
|
|
88
|
+
date_to: str | None = None,
|
|
89
|
+
page: int = 1,
|
|
90
|
+
limit: int = 50,
|
|
91
|
+
) -> Any:
|
|
92
|
+
"""List the project's run history (paginated). status is one of QUEUED,
|
|
93
|
+
RUNNING, COMPLETED, ERROR, FAILED. Dates are ISO 8601."""
|
|
94
|
+
client = await get_client()
|
|
95
|
+
return await client.list_executions(
|
|
96
|
+
workflow_id=workflow_id,
|
|
97
|
+
status=status,
|
|
98
|
+
date_from=date_from,
|
|
99
|
+
date_to=date_to,
|
|
100
|
+
page=page,
|
|
101
|
+
limit=limit,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@mcp.tool
|
|
105
|
+
async def get_execution_detail(execution_id: str) -> Any:
|
|
106
|
+
"""Get step-level detail for a run (node inputs/outputs, errors, timing) —
|
|
107
|
+
use this to diagnose why a run failed."""
|
|
108
|
+
client = await get_client()
|
|
109
|
+
return await client.get_execution_detail(execution_id)
|
|
110
|
+
|
|
111
|
+
@mcp.tool
|
|
112
|
+
async def list_in_flight() -> list:
|
|
113
|
+
"""List runs currently QUEUED or RUNNING in the project."""
|
|
114
|
+
client = await get_client()
|
|
115
|
+
return await client.list_in_flight()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Workflow authoring tools. project_id is implicit in the API key."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Awaitable, Callable
|
|
6
|
+
|
|
7
|
+
from fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from assemblix_mcp.client import AssemblixClient
|
|
10
|
+
|
|
11
|
+
GetClient = Callable[[], Awaitable[AssemblixClient]]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register_workflow_tools(mcp: FastMCP, get_client: GetClient) -> None:
|
|
15
|
+
@mcp.tool
|
|
16
|
+
async def list_node_types() -> list:
|
|
17
|
+
"""List available node types with their config schemas, for building a
|
|
18
|
+
workflow graph. Note: START is implicit and not listed here — see the
|
|
19
|
+
example workflows (resources) for its shape and the create->update->
|
|
20
|
+
publish->run lifecycle."""
|
|
21
|
+
client = await get_client()
|
|
22
|
+
return await client.list_node_types()
|
|
23
|
+
|
|
24
|
+
@mcp.tool
|
|
25
|
+
async def list_workflows(
|
|
26
|
+
is_active: bool | None = None,
|
|
27
|
+
is_published: bool | None = None,
|
|
28
|
+
is_template: bool | None = None,
|
|
29
|
+
) -> list:
|
|
30
|
+
"""List the project's workflows (optionally filtered by status)."""
|
|
31
|
+
client = await get_client()
|
|
32
|
+
return await client.list_workflows(
|
|
33
|
+
is_active=is_active, is_published=is_published, is_template=is_template
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@mcp.tool
|
|
37
|
+
async def get_workflow(workflow_id: str) -> Any:
|
|
38
|
+
"""Get a workflow by id, including its nodes, edges and state."""
|
|
39
|
+
client = await get_client()
|
|
40
|
+
return await client.get_workflow(workflow_id)
|
|
41
|
+
|
|
42
|
+
@mcp.tool
|
|
43
|
+
async def create_workflow(name: str, description: str | None = None) -> Any:
|
|
44
|
+
"""Create a new draft workflow in the project. Add nodes/edges with
|
|
45
|
+
update_workflow, then publish_workflow before running."""
|
|
46
|
+
client = await get_client()
|
|
47
|
+
return await client.create_workflow(name=name, description=description)
|
|
48
|
+
|
|
49
|
+
@mcp.tool
|
|
50
|
+
async def update_workflow(
|
|
51
|
+
workflow_id: str,
|
|
52
|
+
name: str | None = None,
|
|
53
|
+
description: str | None = None,
|
|
54
|
+
nodes: list | None = None,
|
|
55
|
+
edges: list | None = None,
|
|
56
|
+
state: list | None = None,
|
|
57
|
+
) -> Any:
|
|
58
|
+
"""Replace a draft workflow's fields. nodes/edges/state fully replace the
|
|
59
|
+
existing graph when provided; omit to leave unchanged."""
|
|
60
|
+
client = await get_client()
|
|
61
|
+
return await client.update_workflow(
|
|
62
|
+
workflow_id,
|
|
63
|
+
name=name,
|
|
64
|
+
description=description,
|
|
65
|
+
nodes=nodes,
|
|
66
|
+
edges=edges,
|
|
67
|
+
state=state,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@mcp.tool
|
|
71
|
+
async def publish_workflow(workflow_id: str) -> Any:
|
|
72
|
+
"""Publish the draft as the runnable snapshot. run_workflow always
|
|
73
|
+
executes the published version, so publish after every edit you want to
|
|
74
|
+
run."""
|
|
75
|
+
client = await get_client()
|
|
76
|
+
return await client.publish_workflow(workflow_id)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import respx
|
|
3
|
+
|
|
4
|
+
from assemblix_mcp.client import AssemblixClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _client():
|
|
8
|
+
return AssemblixClient(base_url="http://api.test", api_key="sk_k", project_id="p1")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@respx.mock
|
|
12
|
+
async def test_execute_workflow_task_mode_body():
|
|
13
|
+
route = respx.post("http://api.test/api/workflows/w1/execute").mock(
|
|
14
|
+
return_value=httpx.Response(200, json={"executionId": "e1", "status": "running"})
|
|
15
|
+
)
|
|
16
|
+
result = await _client().execute_workflow("w1", input={"message": "hi"}, task=True)
|
|
17
|
+
assert result["executionId"] == "e1"
|
|
18
|
+
body = route.calls.last.request.content
|
|
19
|
+
assert b'"input"' in body
|
|
20
|
+
assert b'"task"' in body
|
|
21
|
+
assert b"project_id" not in body and b"projectId" not in body
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@respx.mock
|
|
25
|
+
async def test_get_task_result():
|
|
26
|
+
respx.get("http://api.test/api/executions/task/e1").mock(
|
|
27
|
+
return_value=httpx.Response(200, json={"executionId": "e1", "status": "completed"})
|
|
28
|
+
)
|
|
29
|
+
result = await _client().get_task_result("e1")
|
|
30
|
+
assert result["status"] == "completed"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@respx.mock
|
|
34
|
+
async def test_list_executions_snake_query():
|
|
35
|
+
route = respx.get("http://api.test/api/executions/").mock(
|
|
36
|
+
return_value=httpx.Response(200, json={"data": [], "total": 0, "page": 1, "limit": 50})
|
|
37
|
+
)
|
|
38
|
+
await _client().list_executions(workflow_id="w1")
|
|
39
|
+
params = route.calls.last.request.url.params
|
|
40
|
+
assert params["project_id"] == "p1"
|
|
41
|
+
assert params["workflow_id"] == "w1"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@respx.mock
|
|
45
|
+
async def test_list_in_flight():
|
|
46
|
+
respx.get("http://api.test/api/executions/in-flight").mock(
|
|
47
|
+
return_value=httpx.Response(200, json=[{"id": "e1", "status": "RUNNING"}])
|
|
48
|
+
)
|
|
49
|
+
assert await _client().list_in_flight() == [{"id": "e1", "status": "RUNNING"}]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import respx
|
|
3
|
+
|
|
4
|
+
from assemblix_mcp.client import AssemblixAPIError, AssemblixClient, fetch_project_id
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _client():
|
|
8
|
+
return AssemblixClient(base_url="http://api.test", api_key="sk_k", project_id="p1")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@respx.mock
|
|
12
|
+
async def test_list_workflows_sends_bearer_and_snake_project():
|
|
13
|
+
route = respx.get("http://api.test/api/workflows/").mock(
|
|
14
|
+
return_value=httpx.Response(200, json=[{"id": "w1"}])
|
|
15
|
+
)
|
|
16
|
+
result = await _client().list_workflows(is_active=True)
|
|
17
|
+
assert result == [{"id": "w1"}]
|
|
18
|
+
sent = route.calls.last.request
|
|
19
|
+
assert sent.headers["authorization"] == "Bearer sk_k"
|
|
20
|
+
assert sent.url.params["project_id"] == "p1"
|
|
21
|
+
assert sent.url.params["is_active"] == "true"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@respx.mock
|
|
25
|
+
async def test_create_workflow_posts_camel_body():
|
|
26
|
+
route = respx.post("http://api.test/api/workflows/").mock(
|
|
27
|
+
return_value=httpx.Response(201, json={"id": "w2", "name": "New"})
|
|
28
|
+
)
|
|
29
|
+
result = await _client().create_workflow(name="New")
|
|
30
|
+
assert result["id"] == "w2"
|
|
31
|
+
body = route.calls.last.request.content
|
|
32
|
+
assert b'"projectId"' in body
|
|
33
|
+
assert b'"name"' in body
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@respx.mock
|
|
37
|
+
async def test_error_response_raises_with_detail():
|
|
38
|
+
respx.get("http://api.test/api/workflows/w9").mock(
|
|
39
|
+
return_value=httpx.Response(403, json={"detail": "nope"})
|
|
40
|
+
)
|
|
41
|
+
try:
|
|
42
|
+
await _client().get_workflow("w9")
|
|
43
|
+
assert False, "expected error"
|
|
44
|
+
except AssemblixAPIError as e:
|
|
45
|
+
assert e.status_code == 403
|
|
46
|
+
assert "nope" in e.detail
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@respx.mock
|
|
50
|
+
async def test_fetch_project_id():
|
|
51
|
+
respx.get("http://api.test/api/api-keys/whoami").mock(
|
|
52
|
+
return_value=httpx.Response(200, json={"projectId": "p-42"})
|
|
53
|
+
)
|
|
54
|
+
assert await fetch_project_id("http://api.test", "sk_k") == "p-42"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from assemblix_mcp.config import Settings, resolve_api_key
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _settings(**over):
|
|
7
|
+
base = dict(api_url="http://x", api_key="sk_env", transport="stdio", host="h", port=1)
|
|
8
|
+
base.update(over)
|
|
9
|
+
return Settings(**base)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_resolve_prefers_header():
|
|
13
|
+
assert resolve_api_key(_settings(), "Bearer sk_header") == "sk_header"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_resolve_falls_back_to_env():
|
|
17
|
+
assert resolve_api_key(_settings(), None) == "sk_env"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_resolve_raises_without_any_key():
|
|
21
|
+
with pytest.raises(ValueError):
|
|
22
|
+
resolve_api_key(_settings(api_key=None), None)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import respx
|
|
3
|
+
from fastmcp import Client as MCPClient
|
|
4
|
+
from fastmcp import FastMCP
|
|
5
|
+
|
|
6
|
+
from assemblix_mcp.client import AssemblixClient
|
|
7
|
+
from assemblix_mcp.tools.executions import register_execution_tools
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _server():
|
|
11
|
+
mcp = FastMCP("test")
|
|
12
|
+
|
|
13
|
+
async def get_client():
|
|
14
|
+
return AssemblixClient(base_url="http://api.test", api_key="sk_k", project_id="p1")
|
|
15
|
+
|
|
16
|
+
register_execution_tools(mcp, get_client)
|
|
17
|
+
return mcp
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@respx.mock
|
|
21
|
+
async def test_run_workflow_returns_execution_id():
|
|
22
|
+
respx.post("http://api.test/api/workflows/w1/execute").mock(
|
|
23
|
+
return_value=httpx.Response(200, json={"executionId": "e1", "status": "running"})
|
|
24
|
+
)
|
|
25
|
+
async with MCPClient(_server()) as c:
|
|
26
|
+
result = await c.call_tool("run_workflow", {"workflow_id": "w1", "input": {"message": "hi"}})
|
|
27
|
+
assert result.data["executionId"] == "e1"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@respx.mock
|
|
31
|
+
async def test_run_and_wait_polls_until_completed():
|
|
32
|
+
respx.post("http://api.test/api/workflows/w1/execute").mock(
|
|
33
|
+
return_value=httpx.Response(200, json={"executionId": "e1", "status": "running"})
|
|
34
|
+
)
|
|
35
|
+
respx.get("http://api.test/api/executions/task/e1").mock(
|
|
36
|
+
side_effect=[
|
|
37
|
+
httpx.Response(200, json={"executionId": "e1", "status": "running"}),
|
|
38
|
+
httpx.Response(200, json={"executionId": "e1", "status": "completed", "output": {"a": 1}}),
|
|
39
|
+
]
|
|
40
|
+
)
|
|
41
|
+
async with MCPClient(_server()) as c:
|
|
42
|
+
result = await c.call_tool(
|
|
43
|
+
"run_workflow_and_wait",
|
|
44
|
+
{"workflow_id": "w1", "input": {"message": "hi"}, "poll_interval": 0, "timeout_seconds": 5},
|
|
45
|
+
)
|
|
46
|
+
assert result.data["status"] == "completed"
|
|
47
|
+
assert result.data["output"] == {"a": 1}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@respx.mock
|
|
51
|
+
async def test_run_and_wait_returns_error_payload_without_raising():
|
|
52
|
+
respx.post("http://api.test/api/workflows/w1/execute").mock(
|
|
53
|
+
return_value=httpx.Response(200, json={"executionId": "e1", "status": "running"})
|
|
54
|
+
)
|
|
55
|
+
respx.get("http://api.test/api/executions/task/e1").mock(
|
|
56
|
+
return_value=httpx.Response(200, json={"executionId": "e1", "status": "failed", "error": "no credential"})
|
|
57
|
+
)
|
|
58
|
+
async with MCPClient(_server()) as c:
|
|
59
|
+
result = await c.call_tool(
|
|
60
|
+
"run_workflow_and_wait",
|
|
61
|
+
{"workflow_id": "w1", "input": {"message": "hi"}, "poll_interval": 0, "timeout_seconds": 5},
|
|
62
|
+
)
|
|
63
|
+
assert result.data["status"] == "failed"
|
|
64
|
+
assert result.data["error"] == "no credential"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@respx.mock
|
|
68
|
+
async def test_run_and_wait_times_out_when_never_terminal():
|
|
69
|
+
respx.post("http://api.test/api/workflows/w1/execute").mock(
|
|
70
|
+
return_value=httpx.Response(200, json={"executionId": "e1", "status": "running"})
|
|
71
|
+
)
|
|
72
|
+
respx.get("http://api.test/api/executions/task/e1").mock(
|
|
73
|
+
return_value=httpx.Response(200, json={"executionId": "e1", "status": "running"})
|
|
74
|
+
)
|
|
75
|
+
async with MCPClient(_server()) as c:
|
|
76
|
+
result = await c.call_tool(
|
|
77
|
+
"run_workflow_and_wait",
|
|
78
|
+
{"workflow_id": "w1", "input": {"message": "hi"}, "poll_interval": 0, "timeout_seconds": 0.05},
|
|
79
|
+
)
|
|
80
|
+
assert result.data["timedOut"] is True
|
|
81
|
+
assert result.data["status"] == "running"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from fastmcp import Client as MCPClient
|
|
4
|
+
from fastmcp import FastMCP
|
|
5
|
+
|
|
6
|
+
from assemblix_mcp.resources import register_resources
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _server():
|
|
10
|
+
mcp = FastMCP("test")
|
|
11
|
+
register_resources(mcp)
|
|
12
|
+
return mcp
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def test_minimal_example_is_valid_runnable_shape():
|
|
16
|
+
async with MCPClient(_server()) as c:
|
|
17
|
+
contents = await c.read_resource("assemblix://examples/minimal")
|
|
18
|
+
data = json.loads(contents[0].text)
|
|
19
|
+
types = [n["type"] for n in data["nodes"]]
|
|
20
|
+
assert types == ["start", "agent", "end"]
|
|
21
|
+
# edges wire start->agent->end
|
|
22
|
+
pairs = {(e["source"], e["target"]) for e in data["edges"]}
|
|
23
|
+
assert ("start-1", "agent-1") in pairs
|
|
24
|
+
assert ("agent-1", "end-1") in pairs
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def test_branching_example_uses_condition_handles():
|
|
28
|
+
async with MCPClient(_server()) as c:
|
|
29
|
+
contents = await c.read_resource("assemblix://examples/branching")
|
|
30
|
+
data = json.loads(contents[0].text)
|
|
31
|
+
handles = {e.get("sourceHandle") for e in data["edges"]}
|
|
32
|
+
assert "source_cond-1_0" in handles
|
|
33
|
+
assert "source_cond-1_1" in handles
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def test_author_prompt_registered():
|
|
37
|
+
async with MCPClient(_server()) as c:
|
|
38
|
+
prompts = await c.list_prompts()
|
|
39
|
+
assert any(p.name == "author_workflow" for p in prompts)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import respx
|
|
6
|
+
from fastmcp import Client as MCPClient
|
|
7
|
+
from starlette.datastructures import Headers
|
|
8
|
+
|
|
9
|
+
from assemblix_mcp.config import Settings
|
|
10
|
+
from assemblix_mcp.server import build_server
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _FakeRequest:
|
|
14
|
+
"""Stand-in for starlette Request; only .headers is read by get_http_headers."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, headers: dict[str, str]) -> None:
|
|
17
|
+
self.headers = Headers(headers)
|
|
18
|
+
self.scope: dict[str, object] = {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@respx.mock
|
|
22
|
+
async def test_get_client_reads_authorization_header_over_http():
|
|
23
|
+
"""Regression test: hosted HTTP transport must forward the caller's bearer
|
|
24
|
+
token to the API. fastmcp's get_http_headers() excludes "authorization" by
|
|
25
|
+
default, so build_server's get_client() must pass include={"authorization"}
|
|
26
|
+
to opt it back in. No ASSEMBLIX_API_KEY is set, so if the header is not
|
|
27
|
+
read, resolve_api_key raises and this test fails.
|
|
28
|
+
"""
|
|
29
|
+
settings = Settings(
|
|
30
|
+
api_url="http://api.test", api_key=None, transport="http", host="h", port=1
|
|
31
|
+
)
|
|
32
|
+
mcp = build_server(settings)
|
|
33
|
+
|
|
34
|
+
route = respx.get("http://api.test/api/workflows/").mock(
|
|
35
|
+
return_value=httpx.Response(200, json=[])
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
async def _fake_fetch_project_id(api_url, api_key):
|
|
39
|
+
return "p1"
|
|
40
|
+
|
|
41
|
+
fake_request = _FakeRequest(
|
|
42
|
+
{"Authorization": "Bearer sk_from_header", "X-Benign": "yes"}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
with (
|
|
46
|
+
patch("fastmcp.server.dependencies.get_http_request", return_value=fake_request),
|
|
47
|
+
patch("assemblix_mcp.server.fetch_project_id", _fake_fetch_project_id),
|
|
48
|
+
):
|
|
49
|
+
async with MCPClient(mcp) as c:
|
|
50
|
+
result = await c.call_tool("list_workflows", {})
|
|
51
|
+
|
|
52
|
+
assert result.data == []
|
|
53
|
+
sent_auth = route.calls.last.request.headers["authorization"]
|
|
54
|
+
assert sent_auth == "Bearer sk_from_header"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_build_server_registers_all_tools():
|
|
58
|
+
settings = Settings(
|
|
59
|
+
api_url="http://api.test", api_key="sk_k", transport="stdio", host="h", port=1
|
|
60
|
+
)
|
|
61
|
+
mcp = build_server(settings)
|
|
62
|
+
names = {tool.name for tool in asyncio.run(mcp.list_tools())}
|
|
63
|
+
assert {
|
|
64
|
+
"list_node_types",
|
|
65
|
+
"create_workflow",
|
|
66
|
+
"publish_workflow",
|
|
67
|
+
"run_workflow",
|
|
68
|
+
"run_workflow_and_wait",
|
|
69
|
+
"get_execution",
|
|
70
|
+
"list_executions",
|
|
71
|
+
} <= names
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import respx
|
|
3
|
+
from fastmcp import Client as MCPClient
|
|
4
|
+
from fastmcp import FastMCP
|
|
5
|
+
|
|
6
|
+
from assemblix_mcp.client import AssemblixClient
|
|
7
|
+
from assemblix_mcp.tools.workflows import register_workflow_tools
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _server():
|
|
11
|
+
mcp = FastMCP("test")
|
|
12
|
+
|
|
13
|
+
async def get_client():
|
|
14
|
+
return AssemblixClient(base_url="http://api.test", api_key="sk_k", project_id="p1")
|
|
15
|
+
|
|
16
|
+
register_workflow_tools(mcp, get_client)
|
|
17
|
+
return mcp
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@respx.mock
|
|
21
|
+
async def test_list_node_types_tool():
|
|
22
|
+
respx.get("http://api.test/api/nodes").mock(
|
|
23
|
+
return_value=httpx.Response(200, json=[{"type": "agent"}])
|
|
24
|
+
)
|
|
25
|
+
async with MCPClient(_server()) as c:
|
|
26
|
+
result = await c.call_tool("list_node_types", {})
|
|
27
|
+
assert result.data == [{"type": "agent"}]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@respx.mock
|
|
31
|
+
async def test_create_then_publish():
|
|
32
|
+
respx.post("http://api.test/api/workflows/").mock(
|
|
33
|
+
return_value=httpx.Response(201, json={"id": "w2", "name": "New"})
|
|
34
|
+
)
|
|
35
|
+
respx.post("http://api.test/api/workflows/w2/publish").mock(
|
|
36
|
+
return_value=httpx.Response(200, json={"id": "w2", "isPublished": True})
|
|
37
|
+
)
|
|
38
|
+
async with MCPClient(_server()) as c:
|
|
39
|
+
created = await c.call_tool("create_workflow", {"name": "New"})
|
|
40
|
+
published = await c.call_tool("publish_workflow", {"workflow_id": "w2"})
|
|
41
|
+
assert created.data["id"] == "w2"
|
|
42
|
+
assert published.data["isPublished"] is True
|