gax-cli 0.4.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.
Files changed (66) hide show
  1. gax/__init__.py +3 -0
  2. gax/adapters/__init__.py +3 -0
  3. gax/adapters/base.py +25 -0
  4. gax/adapters/exec_adapter.py +261 -0
  5. gax/adapters/http_adapter.py +33 -0
  6. gax/adapters/k8s_adapter.py +338 -0
  7. gax/adapters/mcp_bridge.py +108 -0
  8. gax/adapters/mock_adapter.py +49 -0
  9. gax/audit.py +54 -0
  10. gax/caps.py +91 -0
  11. gax/cli.py +722 -0
  12. gax/client.py +58 -0
  13. gax/compliance.py +71 -0
  14. gax/config/oauth_providers.yaml +21 -0
  15. gax/config/policy.rego +20 -0
  16. gax/config/policy.yaml +59 -0
  17. gax/daemon.py +187 -0
  18. gax/envelope.py +64 -0
  19. gax/executor.py +151 -0
  20. gax/macaroons_cap.py +80 -0
  21. gax/manifests/aws.s3.list.yaml +20 -0
  22. gax/manifests/demo.echo.yaml +20 -0
  23. gax/manifests/gh.pr.list.yaml +41 -0
  24. gax/manifests/gh.pr.view.yaml +30 -0
  25. gax/manifests/jira.issue.get.yaml +18 -0
  26. gax/manifests/k8s.deployment.scale.yaml +35 -0
  27. gax/manifests/k8s.pod.delete.yaml +30 -0
  28. gax/manifests/kubectl.get.pods.yaml +20 -0
  29. gax/manifests/mcp.github.list_pulls.yaml +29 -0
  30. gax/manifests/pet_findpetsbystatus.yaml +19 -0
  31. gax/manifests/pet_getpetbyid.yaml +19 -0
  32. gax/mcp_client.py +110 -0
  33. gax/mcp_server.py +299 -0
  34. gax/oauth.py +184 -0
  35. gax/onboarding.py +397 -0
  36. gax/opa_policy.py +46 -0
  37. gax/openapi_gen.py +96 -0
  38. gax/otel_audit.py +50 -0
  39. gax/paths.py +46 -0
  40. gax/plan.py +151 -0
  41. gax/policy.py +33 -0
  42. gax/policy_bundle.py +64 -0
  43. gax/profiles/github/gh.issue.list.yaml +25 -0
  44. gax/profiles/github/gh.issue.view.yaml +23 -0
  45. gax/profiles/github/gh.pr.comment.yaml +30 -0
  46. gax/profiles/github/gh.pr.merge.yaml +29 -0
  47. gax/profiles/github/gh.run.list.yaml +22 -0
  48. gax/profiles/k8s/k8s.deployment.list.yaml +17 -0
  49. gax/profiles/k8s/k8s.deployment.restart.yaml +25 -0
  50. gax/profiles/k8s/k8s.namespace.delete.yaml +22 -0
  51. gax/profiles/k8s/k8s.pod.describe.yaml +22 -0
  52. gax/profiles/k8s/k8s.pod.logs.yaml +28 -0
  53. gax/profiles/k8s/k8s.service.list.yaml +17 -0
  54. gax/profiles.py +143 -0
  55. gax/projection.py +42 -0
  56. gax/registry.py +130 -0
  57. gax/schemas/envelope.v1.json +46 -0
  58. gax/secrets.py +49 -0
  59. gax/side_effects.py +67 -0
  60. gax/spiffe.py +32 -0
  61. gax/vault.py +69 -0
  62. gax_cli-0.4.0.dist-info/METADATA +164 -0
  63. gax_cli-0.4.0.dist-info/RECORD +66 -0
  64. gax_cli-0.4.0.dist-info/WHEEL +5 -0
  65. gax_cli-0.4.0.dist-info/entry_points.txt +4 -0
  66. gax_cli-0.4.0.dist-info/top_level.txt +1 -0
gax/mcp_server.py ADDED
@@ -0,0 +1,299 @@
1
+ """
2
+ gax-mcp — expose GAX as an MCP server (stdio JSON-RPC).
3
+
4
+ Any MCP client (Claude Code, Cursor, OpenAI Agent SDK) gets governed shell
5
+ execution with zero GAX-specific integration:
6
+
7
+ claude mcp add gax -- gax-mcp
8
+
9
+ **Why three tools and not one per command.** A naive MCP server publishes one tool
10
+ per capability, so a 43-command registry costs ~44k tokens of schema before the
11
+ first turn. GAX publishes exactly three — `gax_search`, `gax_doc`, `gax_invoke` —
12
+ and keeps the registry behind them. That is the same shape as Anthropic's Tool
13
+ Search / `defer_loading` pattern, so this composes with the platform fix rather
14
+ than competing with it: the client's own tool-search sees a constant-size surface
15
+ no matter how many commands are registered.
16
+
17
+ **The governance boundary is unchanged.** `gax_invoke` calls the same
18
+ `gax.executor.invoke` as the CLI and the daemon, so capability checks, scope
19
+ checks, and policy all run *before* any adapter, and every invoke emits an
20
+ `audit_id`. An MCP client cannot reach a command that is not in the registry, and
21
+ cannot bypass the capability check by phrasing the request differently — the model
22
+ only ever proposes a command name plus args.
23
+
24
+ Transport is stdio JSON-RPC 2.0 written against the stdlib, mirroring
25
+ `gax.mcp_client`, so adding an MCP server adds no dependency.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import sys
33
+ from typing import Any
34
+
35
+ from gax.executor import invoke
36
+ from gax.registry import Registry
37
+
38
+ PROTOCOL_VERSION = "2024-11-05"
39
+ SERVER_NAME = "gax"
40
+ SERVER_VERSION = "0.4"
41
+
42
+ # JSON-RPC 2.0 reserved codes.
43
+ PARSE_ERROR = -32700
44
+ INVALID_REQUEST = -32600
45
+ METHOD_NOT_FOUND = -32601
46
+ INTERNAL_ERROR = -32603
47
+
48
+ TOOLS: list[dict[str, Any]] = [
49
+ {
50
+ "name": "gax_search",
51
+ "description": (
52
+ "Search registered GAX commands by keyword. Use this first to find a "
53
+ "command; it returns names and one-line descriptions only, not full "
54
+ "schemas. Only commands returned here can be invoked."
55
+ ),
56
+ "inputSchema": {
57
+ "type": "object",
58
+ "properties": {
59
+ "query": {
60
+ "type": "string",
61
+ "description": "Keywords, e.g. 'pull requests' or 'pods'.",
62
+ },
63
+ "limit": {
64
+ "type": "integer",
65
+ "description": "Max results (default 5).",
66
+ },
67
+ },
68
+ "required": ["query"],
69
+ },
70
+ },
71
+ {
72
+ "name": "gax_doc",
73
+ "description": (
74
+ "Get arguments, required scopes, and side effects for one GAX command. "
75
+ "Call this after gax_search and before gax_invoke."
76
+ ),
77
+ "inputSchema": {
78
+ "type": "object",
79
+ "properties": {
80
+ "command": {
81
+ "type": "string",
82
+ "description": "Command id, e.g. 'gh.pr.list'.",
83
+ }
84
+ },
85
+ "required": ["command"],
86
+ },
87
+ },
88
+ {
89
+ "name": "gax_invoke",
90
+ "description": (
91
+ "Invoke a registered GAX command. The request is checked against the "
92
+ "capability token, required scopes, and policy BEFORE any backend runs; "
93
+ "denials return ok=false with an error.kind. Every call returns an "
94
+ "audit_id. Unregistered commands cannot be invoked."
95
+ ),
96
+ "inputSchema": {
97
+ "type": "object",
98
+ "properties": {
99
+ "command": {
100
+ "type": "string",
101
+ "description": "Command id, e.g. 'gh.pr.list'.",
102
+ },
103
+ "args": {
104
+ "type": "object",
105
+ "description": "Arguments per gax_doc.",
106
+ },
107
+ "surface": {
108
+ "type": "string",
109
+ "enum": ["model", "human", "full"],
110
+ "description": "Projection; 'model' truncates for the LLM.",
111
+ },
112
+ },
113
+ "required": ["command"],
114
+ },
115
+ },
116
+ ]
117
+
118
+
119
+ class GaxMcpServer:
120
+ """MCP protocol surface over the GAX registry and executor."""
121
+
122
+ def __init__(
123
+ self,
124
+ registry: Registry | None = None,
125
+ *,
126
+ capability: str | None = None,
127
+ ) -> None:
128
+ self._registry = registry or Registry()
129
+ # Read GAX_CAP lazily at call time if not injected, so a capability minted
130
+ # after the server starts is still picked up.
131
+ self._capability = capability
132
+
133
+ # -- tool implementations ---------------------------------------------
134
+
135
+ def _cap(self) -> str | None:
136
+ return self._capability or os.environ.get("GAX_CAP") or None
137
+
138
+ def tool_search(self, args: dict[str, Any]) -> dict[str, Any]:
139
+ query = str(args.get("query", ""))
140
+ limit = int(args.get("limit") or 5)
141
+ hits = self._registry.search(query, limit=limit)
142
+ return {
143
+ "query": query,
144
+ "results": [
145
+ {
146
+ "command": m.command,
147
+ "description": m.description,
148
+ "category": m.category,
149
+ }
150
+ for m in hits
151
+ ],
152
+ }
153
+
154
+ def tool_doc(self, args: dict[str, Any]) -> dict[str, Any]:
155
+ command = str(args.get("command", ""))
156
+ doc = self._registry.doc_stub(command)
157
+ if not doc:
158
+ return {
159
+ "error": "not_found",
160
+ "message": f"unknown command: {command}",
161
+ "hint": "use gax_search to list registered commands",
162
+ }
163
+ return doc
164
+
165
+ def tool_invoke(self, args: dict[str, Any]) -> dict[str, Any]:
166
+ command = str(args.get("command", ""))
167
+ call_args = dict(args.get("args") or {})
168
+ surface = str(args.get("surface") or "model")
169
+ envelope, _exit_code = invoke(
170
+ self._registry,
171
+ command=command,
172
+ args=call_args,
173
+ surface=surface,
174
+ capability=self._cap(),
175
+ )
176
+ return envelope
177
+
178
+ def call_tool(self, name: str, args: dict[str, Any]) -> tuple[dict[str, Any], bool]:
179
+ """Return (payload, is_error). is_error drives MCP's isError flag."""
180
+ if name == "gax_search":
181
+ return self.tool_search(args), False
182
+ if name == "gax_doc":
183
+ result = self.tool_doc(args)
184
+ return result, bool(result.get("error"))
185
+ if name == "gax_invoke":
186
+ envelope = self.tool_invoke(args)
187
+ # A policy denial is a legitimate protocol-level result, but flagging it
188
+ # as isError makes clients surface it instead of treating it as data.
189
+ return envelope, not bool(envelope.get("ok"))
190
+ raise ValueError(f"unknown tool: {name}")
191
+
192
+ # -- JSON-RPC dispatch -------------------------------------------------
193
+
194
+ def handle(self, req: dict[str, Any]) -> dict[str, Any] | None:
195
+ """Handle one request. Returns None for notifications (no id)."""
196
+ method = req.get("method")
197
+ rid = req.get("id")
198
+ params = req.get("params") or {}
199
+
200
+ # Notifications carry no id and must never get a response.
201
+ if rid is None:
202
+ return None
203
+
204
+ if method == "initialize":
205
+ return _result(
206
+ rid,
207
+ {
208
+ "protocolVersion": PROTOCOL_VERSION,
209
+ "capabilities": {"tools": {"listChanged": False}},
210
+ "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
211
+ },
212
+ )
213
+
214
+ if method == "tools/list":
215
+ return _result(rid, {"tools": TOOLS})
216
+
217
+ if method == "tools/call":
218
+ name = str(params.get("name", ""))
219
+ args = dict(params.get("arguments") or {})
220
+ try:
221
+ payload, is_error = self.call_tool(name, args)
222
+ except ValueError as e:
223
+ return _error(rid, METHOD_NOT_FOUND, str(e))
224
+ except Exception as e: # adapter blew up — report, don't crash the server
225
+ return _result(
226
+ rid,
227
+ {
228
+ "content": [{"type": "text", "text": json.dumps({"error": str(e)})}],
229
+ "isError": True,
230
+ },
231
+ )
232
+ return _result(
233
+ rid,
234
+ {
235
+ "content": [
236
+ {"type": "text", "text": json.dumps(payload, ensure_ascii=False)}
237
+ ],
238
+ "isError": is_error,
239
+ },
240
+ )
241
+
242
+ if method == "ping":
243
+ return _result(rid, {})
244
+
245
+ return _error(rid, METHOD_NOT_FOUND, f"unknown method: {method}")
246
+
247
+
248
+ def _result(rid: Any, result: dict[str, Any]) -> dict[str, Any]:
249
+ return {"jsonrpc": "2.0", "id": rid, "result": result}
250
+
251
+
252
+ def _error(rid: Any, code: int, message: str) -> dict[str, Any]:
253
+ return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}
254
+
255
+
256
+ def serve(
257
+ stdin: Any = None,
258
+ stdout: Any = None,
259
+ *,
260
+ registry: Registry | None = None,
261
+ ) -> None:
262
+ """Run the stdio loop until EOF."""
263
+ stdin = stdin or sys.stdin
264
+ stdout = stdout or sys.stdout
265
+ server = GaxMcpServer(registry=registry)
266
+
267
+ for line in stdin:
268
+ line = line.strip()
269
+ if not line:
270
+ continue
271
+ try:
272
+ req = json.loads(line)
273
+ except json.JSONDecodeError:
274
+ _write(stdout, _error(None, PARSE_ERROR, "invalid JSON"))
275
+ continue
276
+ if not isinstance(req, dict):
277
+ _write(stdout, _error(None, INVALID_REQUEST, "request must be an object"))
278
+ continue
279
+ try:
280
+ resp = server.handle(req)
281
+ except Exception as e: # never let one bad request kill the session
282
+ _write(stdout, _error(req.get("id"), INTERNAL_ERROR, str(e)))
283
+ continue
284
+ if resp is not None:
285
+ _write(stdout, resp)
286
+
287
+
288
+ def _write(stream: Any, msg: dict[str, Any]) -> None:
289
+ stream.write(json.dumps(msg, ensure_ascii=False) + "\n")
290
+ stream.flush()
291
+
292
+
293
+ def main() -> None:
294
+ """Console entry point: `gax-mcp`."""
295
+ serve()
296
+
297
+
298
+ if __name__ == "__main__":
299
+ main()
gax/oauth.py ADDED
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import httpx
11
+ import yaml
12
+
13
+ from gax.paths import CONFIG_DIR, GAX_HOME, ensure_gax_home
14
+
15
+ PROVIDERS_PATH = CONFIG_DIR / "oauth_providers.yaml"
16
+
17
+
18
+ @dataclass
19
+ class OAuthProvider:
20
+ name: str
21
+ device_authorization_url: str
22
+ token_url: str
23
+ client_id_env: str
24
+ scopes: list[str]
25
+ audience: str | None = None
26
+
27
+ @property
28
+ def client_id(self) -> str:
29
+ val = os.environ.get(self.client_id_env, "").strip()
30
+ if not val:
31
+ raise ValueError(
32
+ f"Set {self.client_id_env} for OAuth client id (register an OAuth app for provider {self.name})"
33
+ )
34
+ return val
35
+
36
+
37
+ def load_providers() -> dict[str, OAuthProvider]:
38
+ path = PROVIDERS_PATH
39
+ if not path.exists():
40
+ return {}
41
+ raw = yaml.safe_load(path.read_text()) or {}
42
+ out: dict[str, OAuthProvider] = {}
43
+ for name, cfg in (raw.get("providers") or {}).items():
44
+ out[name] = OAuthProvider(
45
+ name=name,
46
+ device_authorization_url=str(cfg["device_authorization_url"]),
47
+ token_url=str(cfg["token_url"]),
48
+ client_id_env=str(cfg.get("client_id_env", f"GAX_{name.upper()}_CLIENT_ID")),
49
+ scopes=list(cfg.get("scopes") or []),
50
+ audience=cfg.get("audience"),
51
+ )
52
+ return out
53
+
54
+
55
+ def token_path(tenant: str, provider: str) -> Path:
56
+ ensure_gax_home()
57
+ d = GAX_HOME / "tokens" / tenant
58
+ d.mkdir(parents=True, exist_ok=True)
59
+ return d / f"{provider}.json"
60
+
61
+
62
+ def save_tokens(tenant: str, provider: str, data: dict[str, Any]) -> Path:
63
+ from gax.secrets import save_secret
64
+
65
+ path = token_path(tenant, provider)
66
+ save_secret(tenant, provider, data, file_path=path)
67
+ return path
68
+
69
+
70
+ def load_tokens(tenant: str, provider: str) -> dict[str, Any] | None:
71
+ from gax.secrets import load_secret
72
+
73
+ return load_secret(tenant, provider, file_path=token_path(tenant, provider))
74
+
75
+
76
+ def device_flow_login(
77
+ provider: OAuthProvider,
78
+ *,
79
+ tenant: str,
80
+ open_browser: bool = True,
81
+ poll_interval: float | None = None,
82
+ ) -> dict[str, Any]:
83
+ """RFC 8628 device authorization grant."""
84
+ body: dict[str, Any] = {
85
+ "client_id": provider.client_id,
86
+ "scope": " ".join(provider.scopes),
87
+ }
88
+ headers = {"Accept": "application/json"}
89
+ if provider.name == "github":
90
+ headers["Content-Type"] = "application/json"
91
+ r = httpx.post(provider.device_authorization_url, json=body, headers=headers, timeout=30.0)
92
+ else:
93
+ r = httpx.post(provider.device_authorization_url, data=body, headers=headers, timeout=30.0)
94
+ r.raise_for_status()
95
+ device = r.json()
96
+
97
+ verification_uri = device.get("verification_uri_complete") or device.get("verification_uri")
98
+ user_code = device.get("user_code", "")
99
+ device_code = device["device_code"]
100
+ interval = poll_interval or float(device.get("interval", 5))
101
+ expires_in = int(device.get("expires_in", 900))
102
+
103
+ click_echo = __import__("click").echo
104
+ click_echo(f"\nVisit: {verification_uri}")
105
+ if user_code:
106
+ click_echo(f"Code: {user_code}\n")
107
+ if open_browser and verification_uri:
108
+ try:
109
+ import webbrowser
110
+
111
+ webbrowser.open(verification_uri)
112
+ except Exception:
113
+ pass
114
+
115
+ deadline = time.time() + expires_in
116
+ token_body: dict[str, Any] = {
117
+ "client_id": provider.client_id,
118
+ "device_code": device_code,
119
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
120
+ }
121
+
122
+ while time.time() < deadline:
123
+ time.sleep(interval)
124
+ if provider.name == "github":
125
+ tr = httpx.post(
126
+ provider.token_url,
127
+ json=token_body,
128
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
129
+ timeout=30.0,
130
+ )
131
+ else:
132
+ tr = httpx.post(
133
+ provider.token_url,
134
+ data=token_body,
135
+ headers={"Accept": "application/json"},
136
+ timeout=30.0,
137
+ )
138
+ if tr.status_code == 200:
139
+ tokens = tr.json()
140
+ if "access_token" in tokens:
141
+ tokens["obtained_at"] = time.time()
142
+ tokens["provider"] = provider.name
143
+ tokens["scopes"] = provider.scopes
144
+ save_tokens(tenant, provider.name, tokens)
145
+ return tokens
146
+ err = tr.json() if tr.headers.get("content-type", "").startswith("application/json") else {}
147
+ error = err.get("error", "")
148
+ if error in ("authorization_pending", "slow_down"):
149
+ if error == "slow_down":
150
+ interval = min(interval + 2, 30)
151
+ continue
152
+ if error == "access_denied":
153
+ raise RuntimeError("User denied authorization")
154
+ if error == "expired_token":
155
+ raise RuntimeError("Device code expired; run login again")
156
+ if tr.status_code >= 400 and error not in ("authorization_pending",):
157
+ raise RuntimeError(f"Token poll failed: {tr.text}")
158
+
159
+ raise RuntimeError("Device flow timed out")
160
+
161
+
162
+ def mint_cap_from_oauth(
163
+ tenant: str,
164
+ provider: str,
165
+ *,
166
+ commands: list[str] | None = None,
167
+ ttl_seconds: int = 3600,
168
+ ) -> str:
169
+ """After OAuth login, mint GAX capability JWT scoped to tenant + provider scopes."""
170
+ from gax.caps import mint_capability
171
+
172
+ tokens = load_tokens(tenant, provider)
173
+ if not tokens:
174
+ raise ValueError(f"No tokens for tenant={tenant} provider={provider}; run gax auth login first")
175
+ scopes = tokens.get("scopes") or tokens.get("scope") or []
176
+ if isinstance(scopes, str):
177
+ scopes = [s for s in scopes.replace(",", " ").split() if s]
178
+ return mint_capability(
179
+ tenant_id=tenant,
180
+ subject=tokens.get("subject") or f"oauth:{provider}",
181
+ commands=commands or ["*"],
182
+ scopes=list(scopes) if scopes else ["*"],
183
+ ttl_seconds=ttl_seconds,
184
+ )