acex-mcp 1.0.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,191 @@
1
+ Metadata-Version: 2.4
2
+ Name: acex-mcp
3
+ Version: 1.0.0
4
+ Summary: ACE-X MCP Server - Model Context Protocol server for ACE-X
5
+ License: AGPL-3.0
6
+ Author: Johan Lahti
7
+ Author-email: johan.lahti@acebit.se
8
+ Requires-Python: >=3.13,<4.0
9
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: acex-client (>=4,<5)
14
+ Requires-Dist: fastmcp (>=2.13.0.2,<3.0.0.0)
15
+ Requires-Dist: httpx (>=0.28.0,<0.29.0)
16
+ Requires-Dist: mcp (>=1.1.0,<2.0.0)
17
+ Description-Content-Type: text/markdown
18
+
19
+ # ACE-X MCP Server
20
+
21
+ A Model Context Protocol server that gives AI assistants read-only access to
22
+ ACE-X: the network devices it manages, the configuration each is intended to
23
+ have, and the configuration snapshots actually collected from them.
24
+
25
+ It holds no data and no credentials of its own. Every tool is a call to the
26
+ ACE-X API made **as the caller**, so the backend authorizes each read against
27
+ that user's own permissions.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install acex-mcp
33
+ ```
34
+
35
+ ## Tools
36
+
37
+ All tools are read-only. Start with `find_nodes` — the `node_id` it returns is
38
+ what every other tool takes.
39
+
40
+ | Tool | What it answers |
41
+ |---|---|
42
+ | `find_nodes` | Which devices exist, filtered by hostname, site, region, role, vendor, OS or status |
43
+ | `get_node` | One device: its identity plus the hardware it runs on |
44
+ | `find_assets` | What physical hardware is in inventory, deployed or spare |
45
+ | `list_sites` | Which sites exist |
46
+ | `list_regions` | Which regions exist, and their sites |
47
+ | `get_desired_config` | The configuration ACE-X intends a device to have, rendered as device commands |
48
+ | `get_observed_config` | A configuration snapshot collected from a device — latest, at a past moment, or by id |
49
+ | `get_running_config` | *Placeholder.* Explains that ACE-X cannot read a device live, and offers the newest snapshot |
50
+ | `list_observed_configs` | A device's configuration history |
51
+ | `diff_observed_configs` | What changed on a device between two snapshots |
52
+ | `get_config_drift` | How a device differs from its intended configuration, compared per component |
53
+ | `get_neighbors` | What is physically cabled to a device, per LLDP |
54
+
55
+ `get_desired_config` and `get_observed_config` take a `section` argument that
56
+ keeps only matching configuration blocks, so a question about one interface does
57
+ not pull a three-thousand-line config into the model's context.
58
+
59
+ ## Resources and prompts
60
+
61
+ | Resource | Contents |
62
+ |---|---|
63
+ | `acex://glossary` | desired vs observed vs running, and what users mean when they say "running config" |
64
+ | `acex://entities` | Field-level entity reference, generated from the backend's own models |
65
+ | `acex://capabilities` | What this server cannot do |
66
+ | `acex://now` | Current UTC time, for resolving "yesterday at 19:00" into a timestamp |
67
+
68
+ Prompts: `explain_config_change`, `assess_config_risk`,
69
+ `assess_config_alignment` — each takes a `diff` and an optional `context`.
70
+
71
+ ## Configuration
72
+
73
+ Only the backend URL is required. OIDC settings are discovered at startup from
74
+ the backend's public `/api/v1/auth/config`, so authentication is configured in
75
+ one place — the backend — and never duplicated here.
76
+
77
+ | Variable | Default | Meaning |
78
+ |---|---|---|
79
+ | `ACEX_API_URL` | `http://localhost:8080` | Backend base URL, **without** `/api/v1` |
80
+ | `ACEX_VERIFY_SSL` | `true` | Verify TLS against the backend and the IdP |
81
+ | `ACEX_MCP_TRANSPORT` | `http` | `http` or `stdio` |
82
+ | `ACEX_MCP_HOST` | `0.0.0.0` | HTTP bind address |
83
+ | `ACEX_MCP_PORT` | `8000` | HTTP port |
84
+ | `ACEX_MCP_PATH` | `/mcp` | HTTP path |
85
+ | `ACEX_MCP_REQUEST_TIMEOUT` | `60` | Seconds per backend request |
86
+ | `ACEX_MCP_MAX_CONFIG_CHARS` | `24000` | Cap on config text per tool call |
87
+
88
+ ## Authentication
89
+
90
+ The two transports authenticate differently, because only one of them has an
91
+ inbound request to take a token from.
92
+
93
+ **HTTP** — the server verifies the incoming bearer token against the backend's
94
+ OIDC issuer, then forwards that same token to the ACE-X API on every tool call.
95
+ Inbound verification is not a second layer of protection over the data (the
96
+ backend authorizes every read regardless); it is what lets an external client
97
+ discover it must log in, makes an expired token fail once at the boundary
98
+ instead of becoming a series of tool errors, and identifies the caller in logs.
99
+
100
+ **stdio** — there is no inbound request, so the process authenticates as the
101
+ user through `acex_client`: `ACEX_CLIENT_ID` + `ACEX_CLIENT_SECRET` for a
102
+ service identity, or an interactive browser login whose token is cached on disk.
103
+
104
+ If the backend reports authentication disabled, this server also runs without
105
+ it, so a Keycloak-less development stack works.
106
+
107
+ ## Usage
108
+
109
+ ### Running the server
110
+
111
+ ```bash
112
+ python3 -m acex_mcp
113
+ ```
114
+
115
+ Or via the console script, which does exactly the same thing:
116
+
117
+ ```bash
118
+ acex-mcp
119
+ ```
120
+
121
+ The Docker Compose stack builds and wires this automatically; the backend
122
+ reaches it at `ACEX_AI_MCP_SERVER_URL=http://mcp-server:8000/mcp`.
123
+
124
+ ### Configuration for Claude Desktop
125
+
126
+ Add to your `claude_desktop_config.json`. `stdio` is required — Claude Desktop
127
+ spawns the process and talks to it over a pipe:
128
+
129
+ ```json
130
+ {
131
+ "mcpServers": {
132
+ "acex": {
133
+ "command": "acex-mcp",
134
+ "env": {
135
+ "ACEX_MCP_TRANSPORT": "stdio",
136
+ "ACEX_API_URL": "https://acex.example.com"
137
+ }
138
+ }
139
+ }
140
+ }
141
+ ```
142
+
143
+ ### Configuration for VS Code with Cline
144
+
145
+ Add to your MCP settings, with the same two environment variables:
146
+
147
+ ```json
148
+ {
149
+ "mcpServers": {
150
+ "acex": {
151
+ "command": "acex-mcp",
152
+ "args": [],
153
+ "env": {
154
+ "ACEX_MCP_TRANSPORT": "stdio",
155
+ "ACEX_API_URL": "https://acex.example.com"
156
+ }
157
+ }
158
+ }
159
+ }
160
+ ```
161
+
162
+ ## Development
163
+
164
+ ```bash
165
+ cd mcp
166
+ poetry install
167
+ ACEX_API_URL=http://localhost:8080 poetry run python -m acex_mcp
168
+ ```
169
+
170
+ ## Testing
171
+
172
+ ```bash
173
+ poetry run pytest
174
+ ```
175
+
176
+ `tests/test_contract.py` guards against the failure that motivated this
177
+ rewrite: tools that called endpoints which had moved, and described fields the
178
+ API does not return. It checks that every client method the tools call still
179
+ exists, and — when a backend is reachable — that every path behind them is
180
+ still in its OpenAPI spec.
181
+
182
+ Interactively, with the MCP Inspector:
183
+
184
+ ```bash
185
+ npx @modelcontextprotocol/inspector acex-mcp
186
+ ```
187
+
188
+ ## License
189
+
190
+ AGPL-3.0 - See [LICENSE](../LICENSE) for details.
191
+
@@ -0,0 +1,172 @@
1
+ # ACE-X MCP Server
2
+
3
+ A Model Context Protocol server that gives AI assistants read-only access to
4
+ ACE-X: the network devices it manages, the configuration each is intended to
5
+ have, and the configuration snapshots actually collected from them.
6
+
7
+ It holds no data and no credentials of its own. Every tool is a call to the
8
+ ACE-X API made **as the caller**, so the backend authorizes each read against
9
+ that user's own permissions.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pip install acex-mcp
15
+ ```
16
+
17
+ ## Tools
18
+
19
+ All tools are read-only. Start with `find_nodes` — the `node_id` it returns is
20
+ what every other tool takes.
21
+
22
+ | Tool | What it answers |
23
+ |---|---|
24
+ | `find_nodes` | Which devices exist, filtered by hostname, site, region, role, vendor, OS or status |
25
+ | `get_node` | One device: its identity plus the hardware it runs on |
26
+ | `find_assets` | What physical hardware is in inventory, deployed or spare |
27
+ | `list_sites` | Which sites exist |
28
+ | `list_regions` | Which regions exist, and their sites |
29
+ | `get_desired_config` | The configuration ACE-X intends a device to have, rendered as device commands |
30
+ | `get_observed_config` | A configuration snapshot collected from a device — latest, at a past moment, or by id |
31
+ | `get_running_config` | *Placeholder.* Explains that ACE-X cannot read a device live, and offers the newest snapshot |
32
+ | `list_observed_configs` | A device's configuration history |
33
+ | `diff_observed_configs` | What changed on a device between two snapshots |
34
+ | `get_config_drift` | How a device differs from its intended configuration, compared per component |
35
+ | `get_neighbors` | What is physically cabled to a device, per LLDP |
36
+
37
+ `get_desired_config` and `get_observed_config` take a `section` argument that
38
+ keeps only matching configuration blocks, so a question about one interface does
39
+ not pull a three-thousand-line config into the model's context.
40
+
41
+ ## Resources and prompts
42
+
43
+ | Resource | Contents |
44
+ |---|---|
45
+ | `acex://glossary` | desired vs observed vs running, and what users mean when they say "running config" |
46
+ | `acex://entities` | Field-level entity reference, generated from the backend's own models |
47
+ | `acex://capabilities` | What this server cannot do |
48
+ | `acex://now` | Current UTC time, for resolving "yesterday at 19:00" into a timestamp |
49
+
50
+ Prompts: `explain_config_change`, `assess_config_risk`,
51
+ `assess_config_alignment` — each takes a `diff` and an optional `context`.
52
+
53
+ ## Configuration
54
+
55
+ Only the backend URL is required. OIDC settings are discovered at startup from
56
+ the backend's public `/api/v1/auth/config`, so authentication is configured in
57
+ one place — the backend — and never duplicated here.
58
+
59
+ | Variable | Default | Meaning |
60
+ |---|---|---|
61
+ | `ACEX_API_URL` | `http://localhost:8080` | Backend base URL, **without** `/api/v1` |
62
+ | `ACEX_VERIFY_SSL` | `true` | Verify TLS against the backend and the IdP |
63
+ | `ACEX_MCP_TRANSPORT` | `http` | `http` or `stdio` |
64
+ | `ACEX_MCP_HOST` | `0.0.0.0` | HTTP bind address |
65
+ | `ACEX_MCP_PORT` | `8000` | HTTP port |
66
+ | `ACEX_MCP_PATH` | `/mcp` | HTTP path |
67
+ | `ACEX_MCP_REQUEST_TIMEOUT` | `60` | Seconds per backend request |
68
+ | `ACEX_MCP_MAX_CONFIG_CHARS` | `24000` | Cap on config text per tool call |
69
+
70
+ ## Authentication
71
+
72
+ The two transports authenticate differently, because only one of them has an
73
+ inbound request to take a token from.
74
+
75
+ **HTTP** — the server verifies the incoming bearer token against the backend's
76
+ OIDC issuer, then forwards that same token to the ACE-X API on every tool call.
77
+ Inbound verification is not a second layer of protection over the data (the
78
+ backend authorizes every read regardless); it is what lets an external client
79
+ discover it must log in, makes an expired token fail once at the boundary
80
+ instead of becoming a series of tool errors, and identifies the caller in logs.
81
+
82
+ **stdio** — there is no inbound request, so the process authenticates as the
83
+ user through `acex_client`: `ACEX_CLIENT_ID` + `ACEX_CLIENT_SECRET` for a
84
+ service identity, or an interactive browser login whose token is cached on disk.
85
+
86
+ If the backend reports authentication disabled, this server also runs without
87
+ it, so a Keycloak-less development stack works.
88
+
89
+ ## Usage
90
+
91
+ ### Running the server
92
+
93
+ ```bash
94
+ python3 -m acex_mcp
95
+ ```
96
+
97
+ Or via the console script, which does exactly the same thing:
98
+
99
+ ```bash
100
+ acex-mcp
101
+ ```
102
+
103
+ The Docker Compose stack builds and wires this automatically; the backend
104
+ reaches it at `ACEX_AI_MCP_SERVER_URL=http://mcp-server:8000/mcp`.
105
+
106
+ ### Configuration for Claude Desktop
107
+
108
+ Add to your `claude_desktop_config.json`. `stdio` is required — Claude Desktop
109
+ spawns the process and talks to it over a pipe:
110
+
111
+ ```json
112
+ {
113
+ "mcpServers": {
114
+ "acex": {
115
+ "command": "acex-mcp",
116
+ "env": {
117
+ "ACEX_MCP_TRANSPORT": "stdio",
118
+ "ACEX_API_URL": "https://acex.example.com"
119
+ }
120
+ }
121
+ }
122
+ }
123
+ ```
124
+
125
+ ### Configuration for VS Code with Cline
126
+
127
+ Add to your MCP settings, with the same two environment variables:
128
+
129
+ ```json
130
+ {
131
+ "mcpServers": {
132
+ "acex": {
133
+ "command": "acex-mcp",
134
+ "args": [],
135
+ "env": {
136
+ "ACEX_MCP_TRANSPORT": "stdio",
137
+ "ACEX_API_URL": "https://acex.example.com"
138
+ }
139
+ }
140
+ }
141
+ }
142
+ ```
143
+
144
+ ## Development
145
+
146
+ ```bash
147
+ cd mcp
148
+ poetry install
149
+ ACEX_API_URL=http://localhost:8080 poetry run python -m acex_mcp
150
+ ```
151
+
152
+ ## Testing
153
+
154
+ ```bash
155
+ poetry run pytest
156
+ ```
157
+
158
+ `tests/test_contract.py` guards against the failure that motivated this
159
+ rewrite: tools that called endpoints which had moved, and described fields the
160
+ API does not return. It checks that every client method the tools call still
161
+ exists, and — when a backend is reachable — that every path behind them is
162
+ still in its OpenAPI spec.
163
+
164
+ Interactively, with the MCP Inspector:
165
+
166
+ ```bash
167
+ npx @modelcontextprotocol/inspector acex-mcp
168
+ ```
169
+
170
+ ## License
171
+
172
+ AGPL-3.0 - See [LICENSE](../LICENSE) for details.
@@ -0,0 +1,32 @@
1
+ [tool.poetry]
2
+ name = "acex-mcp"
3
+ version = "1.0.0"
4
+ description = "ACE-X MCP Server - Model Context Protocol server for ACE-X"
5
+ authors = ["Johan Lahti <johan.lahti@acebit.se>"]
6
+ readme = "README.md"
7
+ license = "AGPL-3.0"
8
+ packages = [
9
+ { include = "acex_mcp", from = "src" }
10
+ ]
11
+
12
+ [tool.poetry.dependencies]
13
+ python = "^3.13"
14
+ mcp = "^1.1.0"
15
+ fastmcp = "^2.13.0.2"
16
+ httpx = "^0.28.0"
17
+ acex-client = "^4"
18
+
19
+ [tool.poetry.scripts]
20
+ acex-mcp = "acex_mcp.server:run"
21
+
22
+ [tool.poetry.group.dev.dependencies]
23
+ pytest = "^8.0.0"
24
+ pytest-asyncio = "^0.23.0"
25
+
26
+ [tool.pytest.ini_options]
27
+ asyncio_mode = "auto"
28
+ testpaths = ["tests"]
29
+
30
+ [build-system]
31
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
32
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,8 @@
1
+ """ACE-X MCP Server - Model Context Protocol server for ACE-X."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("acex-mcp")
7
+ except PackageNotFoundError: # source tree without an install
8
+ __version__ = "0.0.0"
@@ -0,0 +1,10 @@
1
+ """Entry point for `python -m acex_mcp`.
2
+
3
+ Delegates to the same run() the `acex-mcp` console script uses, so both ways
4
+ of starting the server behave identically.
5
+ """
6
+
7
+ from acex_mcp.server import run
8
+
9
+ if __name__ == "__main__":
10
+ run()
@@ -0,0 +1,52 @@
1
+ """Assembly of the MCP server."""
2
+
3
+ import logging
4
+
5
+ from fastmcp import FastMCP
6
+
7
+ from . import prompts, resources, tools
8
+ from .auth import build_verifier
9
+ from .config import settings
10
+ from .middleware import CallerTokenMiddleware, ToolLoggingMiddleware
11
+
12
+ INSTRUCTIONS = """\
13
+ ACE-X is an infrastructure-as-code platform for network devices: it holds the
14
+ configuration each device is intended to have, and snapshots of what has
15
+ actually been collected from them.
16
+
17
+ Start with find_nodes() to locate a device — every other tool takes the node_id
18
+ it returns. Read the acex://glossary resource before answering configuration
19
+ questions; ACE-X distinguishes desired configuration from observed snapshots,
20
+ and the difference matters. All tools are read-only.
21
+ """
22
+
23
+
24
+ def create_app() -> FastMCP:
25
+ logging.basicConfig(
26
+ level=logging.INFO,
27
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
28
+ )
29
+
30
+ mcp = FastMCP(
31
+ "ACE-X",
32
+ instructions=INSTRUCTIONS,
33
+ auth=build_verifier(),
34
+ )
35
+
36
+ mcp.add_middleware(CallerTokenMiddleware())
37
+ mcp.add_middleware(ToolLoggingMiddleware())
38
+
39
+ tools.register_all(mcp)
40
+ resources.register(mcp)
41
+ prompts.register(mcp)
42
+
43
+ return mcp
44
+
45
+
46
+ def run() -> None:
47
+ """Entry point for the `acex-mcp` command."""
48
+ mcp = create_app()
49
+ if settings.is_stdio:
50
+ mcp.run(transport="stdio")
51
+ else:
52
+ mcp.run(transport="http", host=settings.host, port=settings.port, path=settings.path)
@@ -0,0 +1,73 @@
1
+ """Inbound authentication, configured from the backend.
2
+
3
+ The backend is the only place OIDC is configured, so this server asks it rather
4
+ than duplicating OIDC_* env vars: `GET /api/v1/auth/config` is public and
5
+ reports the issuer (as `authority`) and the expected audience (as `client_id`).
6
+
7
+ Note what this is *for*. It is not a second layer of protection over the data —
8
+ the backend authorizes every read regardless. It exists so an external client
9
+ gets a proper 401 pointing at the OAuth metadata and can start a login flow, so
10
+ an expired token fails once at the boundary instead of becoming N tool errors
11
+ fed to the model as prose, and so calls can be logged per user.
12
+ """
13
+
14
+ import logging
15
+
16
+ import httpx
17
+ from fastmcp.server.auth.providers.jwt import JWTVerifier
18
+
19
+ from .config import settings
20
+
21
+ logger = logging.getLogger("acex_mcp.auth")
22
+
23
+
24
+ class AuthUnavailableError(RuntimeError):
25
+ """The backend could not be asked what the auth settings are."""
26
+
27
+
28
+ def _discover(authority: str) -> str:
29
+ resp = httpx.get(
30
+ f"{authority.rstrip('/')}/.well-known/openid-configuration",
31
+ verify=settings.verify_ssl,
32
+ timeout=10,
33
+ )
34
+ resp.raise_for_status()
35
+ jwks_uri = resp.json().get("jwks_uri")
36
+ if not jwks_uri:
37
+ raise AuthUnavailableError(f"OIDC discovery at {authority} returned no jwks_uri")
38
+ return jwks_uri
39
+
40
+
41
+ def build_verifier() -> JWTVerifier | None:
42
+ """Build a token verifier from the backend's auth config.
43
+
44
+ Returns None when the backend reports auth disabled — the server then runs
45
+ unauthenticated, mirroring the backend so a Keycloak-less dev stack works.
46
+
47
+ Raises AuthUnavailableError if the backend cannot be reached, so startup
48
+ fails loudly rather than silently serving without auth.
49
+ """
50
+ if settings.is_stdio:
51
+ # The transport is a pipe from a process the user started themselves;
52
+ # there is no inbound request to authenticate.
53
+ return None
54
+
55
+ try:
56
+ resp = httpx.get(f"{settings.api_url}/api/v1/auth/config", verify=settings.verify_ssl, timeout=10)
57
+ resp.raise_for_status()
58
+ config = resp.json()
59
+ except Exception as exc:
60
+ raise AuthUnavailableError(
61
+ f"Cannot read auth config from {settings.api_url} — refusing to start without knowing "
62
+ f"whether authentication is required: {exc}"
63
+ ) from exc
64
+
65
+ if not config.get("enabled"):
66
+ logger.warning("backend reports auth disabled — serving MCP without inbound authentication")
67
+ return None
68
+
69
+ authority = config["authority"]
70
+ audience = config.get("client_id")
71
+ verifier = JWTVerifier(jwks_uri=_discover(authority), issuer=authority, audience=audience)
72
+ logger.info("inbound auth enabled (issuer=%s, audience=%s)", authority, audience)
73
+ return verifier
@@ -0,0 +1,96 @@
1
+ """The single path from a tool to the ACE-X backend.
2
+
3
+ Two things matter here.
4
+
5
+ **The MCP server holds no credentials of its own.** Over HTTP it forwards the
6
+ caller's bearer token verbatim, so every read is authorized as that user by the
7
+ backend. A service account would make this server a confused deputy: anyone who
8
+ reached its port would get fleet-wide read access. Over stdio there is no
9
+ inbound request, so the process authenticates as the user through acex_client's
10
+ own env/browser flow instead.
11
+
12
+ **The token is per-request, the connection pool is not.** One shared `Acex`
13
+ instance keeps httpx's pool warm; the token travels in a ContextVar that
14
+ `_ContextTokenAuth` reads on each request. `asyncio.to_thread` copies the
15
+ current context, so the token set by the tool coroutine is visible to the
16
+ synchronous client running in the worker thread.
17
+ """
18
+
19
+ import asyncio
20
+ import contextvars
21
+ import logging
22
+ from collections.abc import Callable
23
+ from typing import Any
24
+
25
+ from acex_client import Acex
26
+ from acex_client.auth import AuthProvider, create_auth_provider
27
+
28
+ from .config import settings
29
+ from .errors import as_tool_error
30
+
31
+ logger = logging.getLogger("acex_mcp.backend")
32
+
33
+ _caller_token: contextvars.ContextVar[str | None] = contextvars.ContextVar("acex_caller_token", default=None)
34
+
35
+
36
+ class _ContextTokenAuth(AuthProvider):
37
+ """Yields whichever token the current request put in context."""
38
+
39
+ def get_token(self) -> str:
40
+ return _caller_token.get() or ""
41
+
42
+
43
+ def set_caller_token(token: str | None) -> None:
44
+ _caller_token.set(token)
45
+
46
+
47
+ _client: Acex | None = None
48
+
49
+
50
+ def client() -> Acex:
51
+ """The process-wide backend client, built on first use.
52
+
53
+ Construction is lazy because in HTTP mode it must not depend on the backend
54
+ being reachable at import time.
55
+ """
56
+ global _client
57
+ if _client is None:
58
+ auth: AuthProvider
59
+ if settings.is_stdio:
60
+ # No inbound request to borrow a token from — authenticate as the
61
+ # user running us (client-credentials env vars, or browser flow).
62
+ auth = create_auth_provider(f"{settings.api_url}/api/v1", settings.verify_ssl)
63
+ else:
64
+ auth = _ContextTokenAuth()
65
+ _client = Acex(
66
+ base_url=settings.api_url,
67
+ verify=settings.verify_ssl,
68
+ auth=auth,
69
+ timeout=settings.request_timeout,
70
+ )
71
+ logger.info("backend client ready (%s, transport=%s)", settings.api_url, settings.transport)
72
+ return _client
73
+
74
+
75
+ async def call[T](fn: Callable[..., T], *args: Any, doing: str, **kwargs: Any) -> T:
76
+ """Run a synchronous acex_client call off the event loop.
77
+
78
+ acex_client is sync (httpx.Client), so calling it directly from a tool
79
+ coroutine would block every other in-flight request.
80
+ """
81
+ try:
82
+ return await asyncio.to_thread(fn, *args, **kwargs)
83
+ except Exception as exc:
84
+ logger.info("backend call failed while %s: %s", doing, exc)
85
+ raise as_tool_error(exc, doing=doing) from exc
86
+
87
+
88
+ async def get_json(path: str, *, params: dict[str, Any] | None = None, doing: str) -> Any:
89
+ """Fetch a raw JSON body, bypassing acex_client's typed models.
90
+
91
+ Used only for `intent_diff`, whose `Diff` model carries a `component_type:
92
+ type[Any]` field that does not survive a JSON round-trip — validating the
93
+ response back into it would fail. Everything else goes through the typed
94
+ resource methods.
95
+ """
96
+ return await call(client().rest.request, "GET", path, params=params, doing=doing)