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.
@@ -0,0 +1,16 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ venv/
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+
10
+ # Tooling / test caches
11
+ .pytest_cache/
12
+ .ruff_cache/
13
+ .mypy_cache/
14
+
15
+ # SDD controller scratch (git-ignored working files)
16
+ .superpowers/
@@ -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,3 @@
1
+ """Assemblix MCP server package."""
2
+
3
+ __version__ = "0.1.0"
@@ -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