grp-mcp 0.2.0__py3-none-any.whl
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.
- grp_mcp/__init__.py +3 -0
- grp_mcp/acumatica.py +505 -0
- grp_mcp/config.py +157 -0
- grp_mcp/customization.py +167 -0
- grp_mcp/loaders.py +75 -0
- grp_mcp/server.py +1436 -0
- grp_mcp/ui.py +319 -0
- grp_mcp-0.2.0.dist-info/METADATA +381 -0
- grp_mcp-0.2.0.dist-info/RECORD +11 -0
- grp_mcp-0.2.0.dist-info/WHEEL +4 -0
- grp_mcp-0.2.0.dist-info/entry_points.txt +3 -0
grp_mcp/__init__.py
ADDED
grp_mcp/acumatica.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""Async Acumatica REST client with OAuth2 (resource-owner-password grant).
|
|
2
|
+
|
|
3
|
+
One client per Instance. Handles token fetch + refresh, then exposes thin
|
|
4
|
+
helpers over the contract-based REST endpoint and OData (Generic Inquiries).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .config import Instance
|
|
18
|
+
|
|
19
|
+
# refresh a little before the token actually expires
|
|
20
|
+
_EXPIRY_SKEW = 30.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AcumaticaError(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AcumaticaClient:
|
|
28
|
+
def __init__(self, instance: Instance) -> None:
|
|
29
|
+
self.instance = instance
|
|
30
|
+
self._http = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
|
|
31
|
+
self._access_token: str | None = None
|
|
32
|
+
self._refresh_token: str | None = None
|
|
33
|
+
self._expires_at: float = 0.0
|
|
34
|
+
self._swagger: dict | None = None
|
|
35
|
+
self._token_lock = asyncio.Lock() # serialize token fetch -> one session, not N
|
|
36
|
+
|
|
37
|
+
async def aclose(self) -> None:
|
|
38
|
+
await self.logout()
|
|
39
|
+
try:
|
|
40
|
+
await self._http.aclose()
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
async def logout(self) -> None:
|
|
45
|
+
"""Close the API session to free the license seat (trial = 2 seats).
|
|
46
|
+
|
|
47
|
+
Acumatica counts each unclosed sign-in against the Max Web Services API
|
|
48
|
+
Users limit, so callers must release the session when done. Uses a fresh,
|
|
49
|
+
short-lived httpx client so it works even from a shutdown handler running
|
|
50
|
+
on a different event loop than the one self._http was created on.
|
|
51
|
+
"""
|
|
52
|
+
token = self._access_token
|
|
53
|
+
self._access_token = None
|
|
54
|
+
self._expires_at = 0.0
|
|
55
|
+
if not token:
|
|
56
|
+
return
|
|
57
|
+
try:
|
|
58
|
+
async with httpx.AsyncClient(timeout=10.0) as h:
|
|
59
|
+
await h.post(
|
|
60
|
+
f"{self.instance.base_url.rstrip('/')}/entity/auth/logout",
|
|
61
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
62
|
+
)
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
# ---- auth -----------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
async def _fetch_token(self) -> None:
|
|
69
|
+
inst = self.instance
|
|
70
|
+
if self._refresh_token:
|
|
71
|
+
data = {
|
|
72
|
+
"grant_type": "refresh_token",
|
|
73
|
+
"refresh_token": self._refresh_token,
|
|
74
|
+
"client_id": inst.client_id,
|
|
75
|
+
"client_secret": inst.client_secret,
|
|
76
|
+
}
|
|
77
|
+
else:
|
|
78
|
+
data = {
|
|
79
|
+
"grant_type": "password",
|
|
80
|
+
"username": inst.username,
|
|
81
|
+
"password": inst.password,
|
|
82
|
+
"client_id": inst.client_id,
|
|
83
|
+
"client_secret": inst.client_secret,
|
|
84
|
+
"scope": "api offline_access",
|
|
85
|
+
}
|
|
86
|
+
resp = await self._http.post(
|
|
87
|
+
inst.token_url,
|
|
88
|
+
data=data,
|
|
89
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
90
|
+
)
|
|
91
|
+
if resp.status_code != 200:
|
|
92
|
+
# a stale refresh token fails -> drop it and retry with password
|
|
93
|
+
if self._refresh_token:
|
|
94
|
+
self._refresh_token = None
|
|
95
|
+
await self._fetch_token()
|
|
96
|
+
return
|
|
97
|
+
raise AcumaticaError(
|
|
98
|
+
f"OAuth token request failed ({resp.status_code}): {resp.text}"
|
|
99
|
+
)
|
|
100
|
+
payload = resp.json()
|
|
101
|
+
self._access_token = payload["access_token"]
|
|
102
|
+
self._refresh_token = payload.get("refresh_token", self._refresh_token)
|
|
103
|
+
self._expires_at = time.monotonic() + float(payload.get("expires_in", 3600))
|
|
104
|
+
|
|
105
|
+
async def _auth_header(self) -> dict[str, str]:
|
|
106
|
+
if not self._access_token or time.monotonic() >= self._expires_at - _EXPIRY_SKEW:
|
|
107
|
+
# serialize concurrent refreshes; re-check inside the lock so only the
|
|
108
|
+
# first waiter actually logs in (avoids duplicate Acumatica sessions /
|
|
109
|
+
# seat exhaustion when many tool calls fire at once)
|
|
110
|
+
async with self._token_lock:
|
|
111
|
+
if not self._access_token or time.monotonic() >= self._expires_at - _EXPIRY_SKEW:
|
|
112
|
+
await self._fetch_token()
|
|
113
|
+
return {"Authorization": f"Bearer {self._access_token}"}
|
|
114
|
+
|
|
115
|
+
def _assert_same_origin(self, url: str) -> None:
|
|
116
|
+
"""Refuse to attach the ERP bearer token to any non-configured origin (SSRF)."""
|
|
117
|
+
u = urlparse(url)
|
|
118
|
+
origin = f"{u.scheme}://{u.netloc}".lower()
|
|
119
|
+
if origin != self.instance.origin:
|
|
120
|
+
raise AcumaticaError(
|
|
121
|
+
f"Refusing authenticated request to '{origin}': only the configured "
|
|
122
|
+
f"Acumatica origin '{self.instance.origin}' is allowed. (Blocked to "
|
|
123
|
+
f"prevent leaking the OAuth token to an arbitrary URL.)"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# ---- request plumbing ----------------------------------------------
|
|
127
|
+
|
|
128
|
+
async def _request_raw(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
|
129
|
+
"""Issue an authenticated request and return the raw httpx.Response.
|
|
130
|
+
|
|
131
|
+
Used for binary payloads (file downloads, report PDFs) and when the caller
|
|
132
|
+
needs response headers (e.g. the Location of an async report). Refreshes the
|
|
133
|
+
token once on 401 and raises AcumaticaError on >=400. Rejects any URL whose
|
|
134
|
+
origin is not the configured instance (so the bearer token never leaves it).
|
|
135
|
+
"""
|
|
136
|
+
self._assert_same_origin(url)
|
|
137
|
+
headers = await self._auth_header()
|
|
138
|
+
headers.update(kwargs.pop("headers", {}))
|
|
139
|
+
resp = await self._http.request(method, url, headers=headers, **kwargs)
|
|
140
|
+
if resp.status_code == 401: # token rejected; force one refresh + retry
|
|
141
|
+
self._access_token = None
|
|
142
|
+
headers.update(await self._auth_header())
|
|
143
|
+
resp = await self._http.request(method, url, headers=headers, **kwargs)
|
|
144
|
+
if resp.status_code >= 400:
|
|
145
|
+
raise AcumaticaError(f"{method} {url} -> {resp.status_code}: {resp.text}")
|
|
146
|
+
return resp
|
|
147
|
+
|
|
148
|
+
async def _request(self, method: str, url: str, **kwargs: Any) -> Any:
|
|
149
|
+
headers = {"Accept": "application/json"}
|
|
150
|
+
headers.update(kwargs.pop("headers", {}))
|
|
151
|
+
resp = await self._request_raw(method, url, headers=headers, **kwargs)
|
|
152
|
+
|
|
153
|
+
if resp.status_code == 204 or not resp.content:
|
|
154
|
+
location = resp.headers.get("Location")
|
|
155
|
+
return {"status": resp.status_code, "location": location}
|
|
156
|
+
ctype = resp.headers.get("Content-Type", "")
|
|
157
|
+
return resp.json() if "json" in ctype else resp.text
|
|
158
|
+
|
|
159
|
+
# ---- contract REST API ---------------------------------------------
|
|
160
|
+
|
|
161
|
+
def _entity_url(self, entity: str, suffix: str = "") -> str:
|
|
162
|
+
url = f"{self.instance.entity_base}/{entity}"
|
|
163
|
+
return f"{url}/{suffix}" if suffix else url
|
|
164
|
+
|
|
165
|
+
async def get_entity(
|
|
166
|
+
self, entity: str, record_id: str | None = None, params: dict | None = None
|
|
167
|
+
) -> Any:
|
|
168
|
+
return await self._request(
|
|
169
|
+
"GET", self._entity_url(entity, record_id or ""), params=params or {}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def put_entity(self, entity: str, body: dict) -> Any:
|
|
173
|
+
return await self._request("PUT", self._entity_url(entity), json=body)
|
|
174
|
+
|
|
175
|
+
async def delete_entity(self, entity: str, record_id: str) -> Any:
|
|
176
|
+
return await self._request("DELETE", self._entity_url(entity, record_id))
|
|
177
|
+
|
|
178
|
+
async def invoke_action(self, entity: str, action: str, body: dict) -> Any:
|
|
179
|
+
return await self._request(
|
|
180
|
+
"POST", self._entity_url(entity, action), json=body
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# ---- metadata / discovery ------------------------------------------
|
|
184
|
+
|
|
185
|
+
async def list_endpoints(self) -> Any:
|
|
186
|
+
"""All web service endpoints published on the instance (name/version/href)."""
|
|
187
|
+
url = f"{self.instance.base_url.rstrip('/')}/entity"
|
|
188
|
+
return await self._request("GET", url)
|
|
189
|
+
|
|
190
|
+
async def get_swagger(self, refresh: bool = False) -> dict:
|
|
191
|
+
"""OpenAPI document for the configured endpoint (cached per client).
|
|
192
|
+
|
|
193
|
+
The endpoint-root metadata GET ({endpoint}/{version}/) is often proxy-gated
|
|
194
|
+
(401), so entity/field discovery is sourced from swagger.json instead.
|
|
195
|
+
"""
|
|
196
|
+
if self._swagger is None or refresh:
|
|
197
|
+
url = f"{self.instance.entity_base}/swagger.json"
|
|
198
|
+
self._swagger = await self._request("GET", url)
|
|
199
|
+
return self._swagger
|
|
200
|
+
|
|
201
|
+
async def list_entities(self, refresh: bool = False) -> list[str]:
|
|
202
|
+
"""Top-level entity names exposed by the configured endpoint contract."""
|
|
203
|
+
doc = await self.get_swagger(refresh=refresh)
|
|
204
|
+
tops: set[str] = set()
|
|
205
|
+
for path in doc.get("paths") or {}:
|
|
206
|
+
m = re.match(r"/([^/{]+)", path)
|
|
207
|
+
if m:
|
|
208
|
+
tops.add(m.group(1))
|
|
209
|
+
return sorted(tops)
|
|
210
|
+
|
|
211
|
+
async def list_actions(self, entity: str, refresh: bool = False) -> list[str]:
|
|
212
|
+
"""Action names invokable on an entity (literal POST sub-paths in contract)."""
|
|
213
|
+
doc = await self.get_swagger(refresh=refresh)
|
|
214
|
+
prefix = f"/{entity}/"
|
|
215
|
+
acts: set[str] = set()
|
|
216
|
+
for path, ops in (doc.get("paths") or {}).items():
|
|
217
|
+
if not path.startswith(prefix):
|
|
218
|
+
continue
|
|
219
|
+
seg = path[len(prefix):]
|
|
220
|
+
if seg and "{" not in seg and "/" not in seg:
|
|
221
|
+
if "post" in {k.lower() for k in ops}:
|
|
222
|
+
acts.add(seg)
|
|
223
|
+
return sorted(acts)
|
|
224
|
+
|
|
225
|
+
_META_FIELDS = ("id", "rowNumber", "note", "_links", "custom", "files")
|
|
226
|
+
|
|
227
|
+
async def _merged_props(self, entity: str, refresh: bool = False) -> dict:
|
|
228
|
+
"""Resolve an entity's properties, merging the allOf base + detail parts."""
|
|
229
|
+
doc = await self.get_swagger(refresh=refresh)
|
|
230
|
+
schemas = (doc.get("components") or {}).get("schemas") or {}
|
|
231
|
+
if entity not in schemas:
|
|
232
|
+
close = sorted(s for s in schemas if entity.lower() in s.lower())
|
|
233
|
+
raise AcumaticaError(
|
|
234
|
+
f"Entity '{entity}' not in contract schemas. Similar: {close[:10]}"
|
|
235
|
+
)
|
|
236
|
+
node = schemas[entity]
|
|
237
|
+
props: dict = dict(node.get("properties") or {})
|
|
238
|
+
for part in node.get("allOf") or []:
|
|
239
|
+
if "$ref" not in part: # skip the shared base Entity ref
|
|
240
|
+
props.update(part.get("properties") or {})
|
|
241
|
+
return props
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def _is_detail(spec: Any) -> bool:
|
|
245
|
+
"""A field is detail/nested when it's an array (detail collection)."""
|
|
246
|
+
return isinstance(spec, dict) and spec.get("type") == "array"
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _detail_ref(spec: Any) -> str | None:
|
|
250
|
+
"""Schema name referenced by a detail (array) field's items, if any."""
|
|
251
|
+
if isinstance(spec, dict) and spec.get("type") == "array":
|
|
252
|
+
ref = (spec.get("items") or {}).get("$ref")
|
|
253
|
+
if ref:
|
|
254
|
+
return ref.split("/")[-1]
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
async def detail_fields(self, entity: str, refresh: bool = False) -> set[str]:
|
|
258
|
+
"""Detail/nested (array) field names — omitted from list GETs by Acumatica."""
|
|
259
|
+
try:
|
|
260
|
+
props = await self._merged_props(entity, refresh=refresh)
|
|
261
|
+
except AcumaticaError:
|
|
262
|
+
return set()
|
|
263
|
+
return {n for n, spec in props.items()
|
|
264
|
+
if self._is_detail(spec) and n not in self._META_FIELDS}
|
|
265
|
+
|
|
266
|
+
async def get_entity_schema(self, entity: str, refresh: bool = False) -> dict:
|
|
267
|
+
"""Field names for one entity, split into scalar vs detail (nested) fields.
|
|
268
|
+
|
|
269
|
+
detail_fields are the array/nested collections Acumatica OMITS from a list
|
|
270
|
+
GET — fetch them per record by key (record_id), optionally with expand=.
|
|
271
|
+
"""
|
|
272
|
+
props = await self._merged_props(entity, refresh=refresh)
|
|
273
|
+
scalar, detail = [], []
|
|
274
|
+
for name, spec in props.items():
|
|
275
|
+
if name in self._META_FIELDS:
|
|
276
|
+
continue
|
|
277
|
+
(detail if self._is_detail(spec) else scalar).append(name)
|
|
278
|
+
return {
|
|
279
|
+
"entity": entity,
|
|
280
|
+
"field_count": len(scalar) + len(detail),
|
|
281
|
+
"scalar_fields": sorted(scalar),
|
|
282
|
+
"detail_fields": sorted(detail),
|
|
283
|
+
"note": "detail_fields are omitted from list GETs; retrieve them by "
|
|
284
|
+
"record_id (optionally with expand=) one record at a time.",
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async def get_entity_schema_deep(
|
|
288
|
+
self, entity: str, refresh: bool = False, max_depth: int = 3
|
|
289
|
+
) -> dict:
|
|
290
|
+
"""Full field tree: scalars + every detail collection expanded to ITS OWN
|
|
291
|
+
scalar/detail fields, recursively (cycle-guarded, depth-capped).
|
|
292
|
+
|
|
293
|
+
Covers what get_entity_schema only names: each detail tab's nested fields.
|
|
294
|
+
Returns {entity, field_count, scalar_fields, detail_fields: {name: {item,
|
|
295
|
+
scalar_fields, detail_fields}}}.
|
|
296
|
+
"""
|
|
297
|
+
doc = await self.get_swagger(refresh=refresh)
|
|
298
|
+
schemas = (doc.get("components") or {}).get("schemas") or {}
|
|
299
|
+
if entity not in schemas:
|
|
300
|
+
close = sorted(s for s in schemas if entity.lower() in s.lower())
|
|
301
|
+
raise AcumaticaError(
|
|
302
|
+
f"Entity '{entity}' not in contract schemas. Similar: {close[:10]}"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def merged(name: str) -> dict:
|
|
306
|
+
node = schemas.get(name) or {}
|
|
307
|
+
props: dict = dict(node.get("properties") or {})
|
|
308
|
+
for part in node.get("allOf") or []:
|
|
309
|
+
if "$ref" in part:
|
|
310
|
+
ref = part["$ref"].split("/")[-1]
|
|
311
|
+
if ref != "Entity":
|
|
312
|
+
props.update(merged(ref))
|
|
313
|
+
else:
|
|
314
|
+
props.update(part.get("properties") or {})
|
|
315
|
+
return props
|
|
316
|
+
|
|
317
|
+
counter = {"n": 0}
|
|
318
|
+
|
|
319
|
+
def build(name: str, depth: int, seen: frozenset) -> dict:
|
|
320
|
+
scalar: list[str] = []
|
|
321
|
+
details: dict = {}
|
|
322
|
+
for fn, spec in merged(name).items():
|
|
323
|
+
if fn in self._META_FIELDS or fn == "_workflowActions":
|
|
324
|
+
continue
|
|
325
|
+
counter["n"] += 1
|
|
326
|
+
if self._is_detail(spec):
|
|
327
|
+
ref = self._detail_ref(spec)
|
|
328
|
+
if ref and ref not in seen and depth < max_depth:
|
|
329
|
+
details[fn] = {"item": ref,
|
|
330
|
+
**build(ref, depth + 1, seen | {ref})}
|
|
331
|
+
else:
|
|
332
|
+
details[fn] = {"item": ref, "scalar_fields": [],
|
|
333
|
+
"detail_fields": {},
|
|
334
|
+
"note": "not expanded (cycle or max_depth)"}
|
|
335
|
+
else:
|
|
336
|
+
scalar.append(fn)
|
|
337
|
+
return {"scalar_fields": sorted(scalar), "detail_fields": details}
|
|
338
|
+
|
|
339
|
+
tree = build(entity, 0, frozenset({entity}))
|
|
340
|
+
return {"entity": entity, "deep": True,
|
|
341
|
+
"field_count": counter["n"], **tree}
|
|
342
|
+
|
|
343
|
+
def _abs(self, url: str) -> str:
|
|
344
|
+
"""Resolve a server-returned relative URL to an absolute one.
|
|
345
|
+
|
|
346
|
+
Acumatica's _links/files:put/Location values are SITE-absolute — they already
|
|
347
|
+
include the instance virtual directory (e.g. "/2025R1Setup/entity/..."). Those
|
|
348
|
+
must be joined to the ORIGIN (scheme://host), not base_url, or the site segment
|
|
349
|
+
doubles ("/2025R1Setup/2025R1Setup/...") and 401s. A link WITHOUT the site
|
|
350
|
+
segment is joined to base_url. Absolute http(s) URLs pass through.
|
|
351
|
+
"""
|
|
352
|
+
if url.startswith("http"):
|
|
353
|
+
return url
|
|
354
|
+
site = urlparse(self.instance.base_url).path.rstrip("/") # e.g. "/2025R1Setup"
|
|
355
|
+
if site and url.startswith(site + "/"):
|
|
356
|
+
return f"{self.instance.origin}{url}"
|
|
357
|
+
return f"{self.instance.base_url.rstrip('/')}{url}"
|
|
358
|
+
|
|
359
|
+
async def get_url(self, url: str) -> Any:
|
|
360
|
+
"""GET an absolute or instance-relative URL (e.g. an action's Location)."""
|
|
361
|
+
return await self._request("GET", self._abs(url))
|
|
362
|
+
|
|
363
|
+
async def get_bytes(self, url: str) -> bytes:
|
|
364
|
+
"""GET raw bytes from an absolute or instance-relative URL (file download)."""
|
|
365
|
+
resp = await self._request_raw("GET", self._abs(url))
|
|
366
|
+
return resp.content
|
|
367
|
+
|
|
368
|
+
async def get_all(
|
|
369
|
+
self, entity: str, params: dict | None = None, page_size: int = 1000,
|
|
370
|
+
max_records: int | None = None,
|
|
371
|
+
) -> list:
|
|
372
|
+
"""Retrieve every matching record by paging with $top/$skip.
|
|
373
|
+
|
|
374
|
+
The contract API caps a single list GET (server RowsToFetch / proxy limits),
|
|
375
|
+
so large tables need paging. Issues GETs with increasing $skip until a short
|
|
376
|
+
(or empty) page comes back. Honors any caller $top as the page size and any
|
|
377
|
+
$filter/$select/$expand passed in params.
|
|
378
|
+
"""
|
|
379
|
+
base = dict(params or {})
|
|
380
|
+
size = int(base.pop("$top", page_size) or page_size)
|
|
381
|
+
if size <= 0:
|
|
382
|
+
raise AcumaticaError(f"page_size must be >= 1 (got {size})")
|
|
383
|
+
size = min(size, 10000) # guard against absurd page sizes
|
|
384
|
+
out: list = []
|
|
385
|
+
skip = 0
|
|
386
|
+
while True:
|
|
387
|
+
page = dict(base)
|
|
388
|
+
page["$top"] = size
|
|
389
|
+
page["$skip"] = skip
|
|
390
|
+
chunk = await self.get_entity(entity, None, page)
|
|
391
|
+
if not isinstance(chunk, list):
|
|
392
|
+
# single object or unexpected shape -> return as-is wrapped
|
|
393
|
+
return [chunk] if chunk is not None else out
|
|
394
|
+
out.extend(chunk)
|
|
395
|
+
if max_records is not None and len(out) >= max_records:
|
|
396
|
+
return out[:max_records]
|
|
397
|
+
if len(chunk) < size: # short page = last page
|
|
398
|
+
break
|
|
399
|
+
skip += size
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
# ---- DAC-based OData v4 (raw data access classes) ------------------
|
|
403
|
+
|
|
404
|
+
async def list_dacs(self) -> Any:
|
|
405
|
+
"""OData service document: every DAC exposed via the DAC-based OData interface."""
|
|
406
|
+
return await self._request(
|
|
407
|
+
"GET", self.instance.dac_odata_base, params={"$format": "json"}
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
async def run_dac(self, dac: str, params: dict | None = None) -> Any:
|
|
411
|
+
"""Query one DAC through the DAC-based OData v4 interface (<dac base>/<DAC>)."""
|
|
412
|
+
url = f"{self.instance.dac_odata_base}/{dac}"
|
|
413
|
+
p = {"$format": "json"}
|
|
414
|
+
p.update(params or {})
|
|
415
|
+
return await self._request("GET", url, params=p)
|
|
416
|
+
|
|
417
|
+
async def dac_metadata(self) -> str:
|
|
418
|
+
"""Fetch the DAC-based OData CSDL ($metadata) as XML text.
|
|
419
|
+
|
|
420
|
+
Returns the EDMX/CSDL document describing every exposed DAC: its
|
|
421
|
+
properties, types, key, and Nullable flag (Nullable="false" = mandatory).
|
|
422
|
+
Requested as XML on purpose — this platform's OData layer raises 500 on
|
|
423
|
+
JSON metadata ("only supported at platform implementing .NETStandard 2.0"),
|
|
424
|
+
and does NOT take $format. This is the only reliable mandatory-field source
|
|
425
|
+
for DACs (incl. single-row config DACs like GLSetup that serve no collection).
|
|
426
|
+
"""
|
|
427
|
+
url = f"{self.instance.dac_odata_base}/$metadata"
|
|
428
|
+
return await self._request("GET", url, headers={"Accept": "application/xml"})
|
|
429
|
+
|
|
430
|
+
# ---- report entities (contract API, async) -------------------------
|
|
431
|
+
|
|
432
|
+
async def run_report(
|
|
433
|
+
self, entity: str, body: dict, poll_interval: float = 2.0, timeout: float = 180.0
|
|
434
|
+
) -> bytes:
|
|
435
|
+
"""Run a Report-type entity and return the rendered file bytes (usually PDF).
|
|
436
|
+
|
|
437
|
+
Contract flow: PUT the report entity with its parameters -> 202 + Location ->
|
|
438
|
+
poll the Location (202 while rendering) -> 200 returns the binary file.
|
|
439
|
+
`body` is the already-wrapped request body, e.g.
|
|
440
|
+
{"parameters": {"OrgBAccountID": {"value": "MPM"}}}.
|
|
441
|
+
"""
|
|
442
|
+
poll_interval = max(0.2, float(poll_interval)) # never 0 -> tight spin
|
|
443
|
+
timeout = max(poll_interval, float(timeout))
|
|
444
|
+
|
|
445
|
+
resp = await self._request_raw(
|
|
446
|
+
"PUT", self._entity_url(entity), json=body,
|
|
447
|
+
headers={"Accept": "application/pdf, application/json"},
|
|
448
|
+
)
|
|
449
|
+
if resp.status_code == 200 and resp.content:
|
|
450
|
+
return resp.content
|
|
451
|
+
location = resp.headers.get("Location")
|
|
452
|
+
if not location:
|
|
453
|
+
raise AcumaticaError(
|
|
454
|
+
f"report '{entity}' returned {resp.status_code} with no Location to poll"
|
|
455
|
+
)
|
|
456
|
+
if location.startswith("/"):
|
|
457
|
+
location = f"{self.instance.base_url.rstrip('/')}{location}"
|
|
458
|
+
waited = 0.0
|
|
459
|
+
while waited < timeout:
|
|
460
|
+
r = await self._request_raw(
|
|
461
|
+
"GET", location, headers={"Accept": "application/pdf, application/json"}
|
|
462
|
+
)
|
|
463
|
+
if r.status_code == 200 and r.content:
|
|
464
|
+
return r.content
|
|
465
|
+
await asyncio.sleep(poll_interval)
|
|
466
|
+
waited += poll_interval
|
|
467
|
+
raise AcumaticaError(f"report '{entity}' did not finish within {timeout}s")
|
|
468
|
+
|
|
469
|
+
# ---- file attachments (files:put) ----------------------------------
|
|
470
|
+
|
|
471
|
+
async def put_file(
|
|
472
|
+
self, url: str, content: bytes, content_type: str = "application/octet-stream"
|
|
473
|
+
) -> Any:
|
|
474
|
+
"""PUT raw file bytes to a record's files:put URL (absolute or relative).
|
|
475
|
+
|
|
476
|
+
The {filename} placeholder must already be substituted in `url`.
|
|
477
|
+
"""
|
|
478
|
+
return await self._request(
|
|
479
|
+
"PUT", self._abs(url), content=content, headers={"Content-Type": content_type}
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
async def record_files_put_url(
|
|
483
|
+
self, entity: str, record_id: str, filename: str
|
|
484
|
+
) -> str:
|
|
485
|
+
"""Resolve the files:put URL for a record by reading its _links."""
|
|
486
|
+
rec = await self.get_entity(entity, record_id)
|
|
487
|
+
link = (rec.get("_links") or {}).get("files:put") if isinstance(rec, dict) else None
|
|
488
|
+
if not link:
|
|
489
|
+
raise AcumaticaError(
|
|
490
|
+
f"No files:put link on {entity}/{record_id} - the record may not "
|
|
491
|
+
f"exist or the entity does not support file attachments."
|
|
492
|
+
)
|
|
493
|
+
return link.replace("{filename}", filename)
|
|
494
|
+
|
|
495
|
+
# ---- OData (Generic Inquiries) -------------------------------------
|
|
496
|
+
|
|
497
|
+
async def run_gi(self, name: str, params: dict | None = None) -> Any:
|
|
498
|
+
url = f"{self.instance.odata_base}/{name}"
|
|
499
|
+
return await self._request("GET", url, params=params or {})
|
|
500
|
+
|
|
501
|
+
async def list_generic_inquiries(self) -> Any:
|
|
502
|
+
"""OData service document: the Generic Inquiries exposed via OData."""
|
|
503
|
+
return await self._request(
|
|
504
|
+
"GET", self.instance.odata_base, params={"$format": "json"}
|
|
505
|
+
)
|
grp_mcp/config.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Instance configuration loading.
|
|
2
|
+
|
|
3
|
+
Two sources, in priority order:
|
|
4
|
+
1. connections.json (named profiles, supports many instances)
|
|
5
|
+
2. environment vars (a single instance named "default")
|
|
6
|
+
|
|
7
|
+
A connections file path can be given via GRP_MCP_CONNECTIONS; otherwise a
|
|
8
|
+
connections.json next to the project root is used if present.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from dotenv import load_dotenv
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
load_dotenv()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Instance(BaseModel):
|
|
24
|
+
"""Connection details for one Acumatica tenant."""
|
|
25
|
+
|
|
26
|
+
base_url: str = Field(..., description="Root URL, e.g. https://host/Site")
|
|
27
|
+
client_id: str
|
|
28
|
+
client_secret: str
|
|
29
|
+
username: str
|
|
30
|
+
password: str
|
|
31
|
+
endpoint_name: str = "Default"
|
|
32
|
+
endpoint_version: str = "24.200.001"
|
|
33
|
+
tenant: str = "" # company login name, needed for OData / GI calls
|
|
34
|
+
branch: str = "" # optional login branch
|
|
35
|
+
# --- write gates (default read-only; opt in per instance) ---
|
|
36
|
+
allow_write: bool = False # gate create/update, load, action, import-scenario, note, attach
|
|
37
|
+
allow_delete: bool = False # gate record deletes (stricter than write)
|
|
38
|
+
allow_publish: bool = False # gate for Customization API write ops (publish/import/unpublish)
|
|
39
|
+
# --- filesystem sandbox (empty list = unrestricted; set to enforce) ---
|
|
40
|
+
read_roots: list[str] = Field(default_factory=list) # dirs attach_file may read from
|
|
41
|
+
write_roots: list[str] = Field(default_factory=list) # dirs download/report/snapshot may write to
|
|
42
|
+
max_file_bytes: int = 50_000_000 # cap on read/download size (bytes)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def token_url(self) -> str:
|
|
46
|
+
return f"{self.base_url.rstrip('/')}/identity/connect/token"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def origin(self) -> str:
|
|
50
|
+
"""scheme://host[:port] of the instance — the only origin we send the token to."""
|
|
51
|
+
from urllib.parse import urlparse
|
|
52
|
+
|
|
53
|
+
u = urlparse(self.base_url)
|
|
54
|
+
return f"{u.scheme}://{u.netloc}".lower()
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def entity_base(self) -> str:
|
|
58
|
+
return (
|
|
59
|
+
f"{self.base_url.rstrip('/')}/entity/"
|
|
60
|
+
f"{self.endpoint_name}/{self.endpoint_version}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def odata_base(self) -> str:
|
|
65
|
+
root = self.base_url.rstrip("/")
|
|
66
|
+
return f"{root}/odata/{self.tenant}" if self.tenant else f"{root}/odata"
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def dac_odata_base(self) -> str:
|
|
70
|
+
"""Base URL for the DAC-based OData v4 interface (<base>/t/<Tenant>/api/odata/dac)."""
|
|
71
|
+
root = self.base_url.rstrip("/")
|
|
72
|
+
return f"{root}/t/{self.tenant}/api/odata/dac" if self.tenant else f"{root}/api/odata/dac"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Config(BaseModel):
|
|
76
|
+
default: str
|
|
77
|
+
instances: dict[str, Instance]
|
|
78
|
+
source_path: str | None = None # file this config was loaded from (None = env)
|
|
79
|
+
|
|
80
|
+
def get(self, name: str | None) -> Instance:
|
|
81
|
+
key = name or self.default
|
|
82
|
+
if key not in self.instances:
|
|
83
|
+
raise KeyError(
|
|
84
|
+
f"Unknown instance '{key}'. Configured: {', '.join(self.instances)}"
|
|
85
|
+
)
|
|
86
|
+
return self.instances[key]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def save_config(cfg: Config, path: str | None = None) -> str:
|
|
90
|
+
"""Persist a Config (default + instances, with secrets) back to a JSON file.
|
|
91
|
+
|
|
92
|
+
Writes to `path`, else the file the config came from, else
|
|
93
|
+
$GRP_MCP_CONNECTIONS, else ./connections.json. Returns the path written.
|
|
94
|
+
Updates cfg.source_path so later saves target the same file.
|
|
95
|
+
"""
|
|
96
|
+
target = path or cfg.source_path or os.getenv("GRP_MCP_CONNECTIONS") or str(
|
|
97
|
+
Path.cwd() / "connections.json"
|
|
98
|
+
)
|
|
99
|
+
data = {
|
|
100
|
+
"default": cfg.default,
|
|
101
|
+
"instances": {n: i.model_dump() for n, i in cfg.instances.items()},
|
|
102
|
+
}
|
|
103
|
+
Path(target).write_text(
|
|
104
|
+
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
105
|
+
)
|
|
106
|
+
cfg.source_path = target
|
|
107
|
+
return target
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _from_env() -> Config | None:
|
|
111
|
+
base = os.getenv("GRP_MCP_BASE_URL")
|
|
112
|
+
if not base:
|
|
113
|
+
return None
|
|
114
|
+
inst = Instance(
|
|
115
|
+
base_url=base,
|
|
116
|
+
client_id=os.environ["GRP_MCP_CLIENT_ID"],
|
|
117
|
+
client_secret=os.environ["GRP_MCP_CLIENT_SECRET"],
|
|
118
|
+
username=os.environ["GRP_MCP_USERNAME"],
|
|
119
|
+
password=os.environ["GRP_MCP_PASSWORD"],
|
|
120
|
+
endpoint_name=os.getenv("GRP_MCP_ENDPOINT_NAME", "Default"),
|
|
121
|
+
endpoint_version=os.getenv("GRP_MCP_ENDPOINT_VERSION", "24.200.001"),
|
|
122
|
+
tenant=os.getenv("GRP_MCP_TENANT", ""),
|
|
123
|
+
branch=os.getenv("GRP_MCP_BRANCH", ""),
|
|
124
|
+
allow_write=os.getenv("GRP_MCP_ALLOW_WRITE", "").lower() in ("1", "true", "yes"),
|
|
125
|
+
allow_delete=os.getenv("GRP_MCP_ALLOW_DELETE", "").lower() in ("1", "true", "yes"),
|
|
126
|
+
allow_publish=os.getenv("GRP_MCP_ALLOW_PUBLISH", "").lower() in ("1", "true", "yes"),
|
|
127
|
+
)
|
|
128
|
+
return Config(default="default", instances={"default": inst})
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _from_file(path: Path) -> Config:
|
|
132
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
133
|
+
instances = {k: Instance(**v) for k, v in data["instances"].items()}
|
|
134
|
+
default = data.get("default") or next(iter(instances))
|
|
135
|
+
return Config(default=default, instances=instances, source_path=str(path))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def load_config() -> Config:
|
|
139
|
+
explicit = os.getenv("GRP_MCP_CONNECTIONS")
|
|
140
|
+
candidates = []
|
|
141
|
+
if explicit:
|
|
142
|
+
candidates.append(Path(explicit))
|
|
143
|
+
candidates.append(Path.cwd() / "connections.json")
|
|
144
|
+
candidates.append(Path(__file__).resolve().parents[2] / "connections.json")
|
|
145
|
+
|
|
146
|
+
for path in candidates:
|
|
147
|
+
if path.is_file():
|
|
148
|
+
return _from_file(path)
|
|
149
|
+
|
|
150
|
+
env_cfg = _from_env()
|
|
151
|
+
if env_cfg:
|
|
152
|
+
return env_cfg
|
|
153
|
+
|
|
154
|
+
raise RuntimeError(
|
|
155
|
+
"No configuration found. Set GRP_MCP_* env vars (see .env.example) "
|
|
156
|
+
"or create connections.json (see connections.example.json)."
|
|
157
|
+
)
|