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 ADDED
@@ -0,0 +1,3 @@
1
+ """grp-mcp: MCP server exposing Acumatica ERP REST API as agent tools."""
2
+
3
+ __version__ = "0.1.0"
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
+ )