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.
- acex_mcp-1.0.0/PKG-INFO +191 -0
- acex_mcp-1.0.0/README.md +172 -0
- acex_mcp-1.0.0/pyproject.toml +32 -0
- acex_mcp-1.0.0/src/acex_mcp/__init__.py +8 -0
- acex_mcp-1.0.0/src/acex_mcp/__main__.py +10 -0
- acex_mcp-1.0.0/src/acex_mcp/app.py +52 -0
- acex_mcp-1.0.0/src/acex_mcp/auth.py +73 -0
- acex_mcp-1.0.0/src/acex_mcp/backend.py +96 -0
- acex_mcp-1.0.0/src/acex_mcp/config.py +67 -0
- acex_mcp-1.0.0/src/acex_mcp/errors.py +53 -0
- acex_mcp-1.0.0/src/acex_mcp/middleware.py +53 -0
- acex_mcp-1.0.0/src/acex_mcp/prompts.py +74 -0
- acex_mcp-1.0.0/src/acex_mcp/resources.py +183 -0
- acex_mcp-1.0.0/src/acex_mcp/server.py +12 -0
- acex_mcp-1.0.0/src/acex_mcp/text.py +106 -0
- acex_mcp-1.0.0/src/acex_mcp/tools/__init__.py +9 -0
- acex_mcp-1.0.0/src/acex_mcp/tools/configuration.py +465 -0
- acex_mcp-1.0.0/src/acex_mcp/tools/inventory.py +257 -0
- acex_mcp-1.0.0/src/acex_mcp/tools/topology.py +59 -0
acex_mcp-1.0.0/PKG-INFO
ADDED
|
@@ -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
|
+
|
acex_mcp-1.0.0/README.md
ADDED
|
@@ -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,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)
|