eniyan 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ .venv/
2
+ dist/
3
+ *.egg-info/
4
+ __pycache__/
eniyan-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.5
2
+ Name: eniyan
3
+ Version: 0.1.0
4
+ Summary: Eniyan SDK — govern your AI agents wherever they run: identity, scoped authority, JIT windows, and self-reported run telemetry.
5
+ Project-URL: Homepage, https://eniyantrust.com
6
+ Project-URL: Documentation, https://eniyantrust.com/docs
7
+ Author-email: Eniyan <stevland@eniyantrust.com>
8
+ License-Expression: Apache-2.0
9
+ Keywords: agent-identity,ai-agents,governance,mcp,rbac
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Security
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: httpx>=0.24
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=8; extra == 'dev'
18
+ Provides-Extra: mcp
19
+ Requires-Dist: mcp>=1.0; extra == 'mcp'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Eniyan SDK (Python)
23
+
24
+ Govern your AI agents wherever they run. This SDK wraps the Eniyan API —
25
+ agent identity verification, live scope decisions, JIT credential windows,
26
+ short-lived OAuth tokens, and self-reported run telemetry — so any agent
27
+ loop becomes Eniyan-governed in a few lines. Eniyan never hosts or observes
28
+ your loop; your agent reports metadata-only telemetry (names, scopes, token
29
+ counts, outcomes — never prompts, arguments, results, or model output).
30
+
31
+ ```bash
32
+ pip install eniyan
33
+ ```
34
+
35
+ ## Quickstart
36
+
37
+ ```python
38
+ import os
39
+ from eniyan import EniyanClient, governed_run, EniyanScopeRefused
40
+
41
+ client = EniyanClient(
42
+ base_url="https://api.eniyantrust.com",
43
+ api_key=os.environ["ENIYAN_API_KEY"],
44
+ credential_token=os.environ["ENIYAN_CREDENTIAL_TOKEN"],
45
+ agent_id=os.environ["ENIYAN_AGENT_ID"],
46
+ )
47
+
48
+ with governed_run(client, harness="my-loop", jit=True, scopes=["crm:read"]) as run:
49
+ fetch = run.tool("fetch_accounts", scope="crm:read")(fetch_accounts)
50
+ accounts = fetch() # refused under block mode BEFORE it runs
51
+ run.model_call("claude-sonnet-5", input_tokens=1200, output_tokens=300)
52
+ ```
53
+
54
+ On exit — success or crash — the run is finished, buffered steps are
55
+ flushed, and the JIT window is completed and attested.
56
+
57
+ ## What each piece maps to
58
+
59
+ | SDK call | API |
60
+ |---|---|
61
+ | `client.verify()` / `client.check_scope()` | `POST /v1/credentials/verify` |
62
+ | `client.mint_token()` / `client.introspect()` | `POST /v1/oauth/token` / `/introspect` |
63
+ | `client.validate_grant()` | `POST /v1/agents/credentials/validate` |
64
+ | `client.create_task()` / `complete_task()` / `attest_task()` | JIT task lifecycle |
65
+ | `client.start_run()` / `append_steps()` / `finish_run()` | `POST /v1/runs` |
66
+
67
+ Advisory-mode scope violations do not raise — the call proceeds and the
68
+ violation is flagged server-side (`GovernanceDecision.advisory_flagged`).
69
+ Block mode raises `EniyanScopeRefused` before the tool executes.
70
+
71
+ ## MCP server
72
+
73
+ `pip install "eniyan[mcp]"` and run `eniyan-mcp` to expose the same
74
+ governance operations as MCP tools for Claude Code or any MCP-capable
75
+ harness. See the gated docs for configuration.
76
+
77
+ ## Development
78
+
79
+ ```bash
80
+ pip install -e ".[dev]"
81
+ pytest
82
+ ```
83
+
84
+ `tests/integration_local.py` runs the full flow against a local Eniyan
85
+ stack (`docker compose up` from the repo root).
eniyan-0.1.0/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # Eniyan SDK (Python)
2
+
3
+ Govern your AI agents wherever they run. This SDK wraps the Eniyan API —
4
+ agent identity verification, live scope decisions, JIT credential windows,
5
+ short-lived OAuth tokens, and self-reported run telemetry — so any agent
6
+ loop becomes Eniyan-governed in a few lines. Eniyan never hosts or observes
7
+ your loop; your agent reports metadata-only telemetry (names, scopes, token
8
+ counts, outcomes — never prompts, arguments, results, or model output).
9
+
10
+ ```bash
11
+ pip install eniyan
12
+ ```
13
+
14
+ ## Quickstart
15
+
16
+ ```python
17
+ import os
18
+ from eniyan import EniyanClient, governed_run, EniyanScopeRefused
19
+
20
+ client = EniyanClient(
21
+ base_url="https://api.eniyantrust.com",
22
+ api_key=os.environ["ENIYAN_API_KEY"],
23
+ credential_token=os.environ["ENIYAN_CREDENTIAL_TOKEN"],
24
+ agent_id=os.environ["ENIYAN_AGENT_ID"],
25
+ )
26
+
27
+ with governed_run(client, harness="my-loop", jit=True, scopes=["crm:read"]) as run:
28
+ fetch = run.tool("fetch_accounts", scope="crm:read")(fetch_accounts)
29
+ accounts = fetch() # refused under block mode BEFORE it runs
30
+ run.model_call("claude-sonnet-5", input_tokens=1200, output_tokens=300)
31
+ ```
32
+
33
+ On exit — success or crash — the run is finished, buffered steps are
34
+ flushed, and the JIT window is completed and attested.
35
+
36
+ ## What each piece maps to
37
+
38
+ | SDK call | API |
39
+ |---|---|
40
+ | `client.verify()` / `client.check_scope()` | `POST /v1/credentials/verify` |
41
+ | `client.mint_token()` / `client.introspect()` | `POST /v1/oauth/token` / `/introspect` |
42
+ | `client.validate_grant()` | `POST /v1/agents/credentials/validate` |
43
+ | `client.create_task()` / `complete_task()` / `attest_task()` | JIT task lifecycle |
44
+ | `client.start_run()` / `append_steps()` / `finish_run()` | `POST /v1/runs` |
45
+
46
+ Advisory-mode scope violations do not raise — the call proceeds and the
47
+ violation is flagged server-side (`GovernanceDecision.advisory_flagged`).
48
+ Block mode raises `EniyanScopeRefused` before the tool executes.
49
+
50
+ ## MCP server
51
+
52
+ `pip install "eniyan[mcp]"` and run `eniyan-mcp` to expose the same
53
+ governance operations as MCP tools for Claude Code or any MCP-capable
54
+ harness. See the gated docs for configuration.
55
+
56
+ ## Development
57
+
58
+ ```bash
59
+ pip install -e ".[dev]"
60
+ pytest
61
+ ```
62
+
63
+ `tests/integration_local.py` runs the full flow against a local Eniyan
64
+ stack (`docker compose up` from the repo root).
@@ -0,0 +1,21 @@
1
+ # Releasing `eniyan` to PyPI
2
+
3
+ Manual for now. One-time setup: create the PyPI project `eniyan` and an API
4
+ token scoped to it; keep the token out of the repo.
5
+
6
+ 1. Bump `version` in `pyproject.toml` AND `__version__` in
7
+ `src/eniyan/__init__.py` (keep them identical).
8
+ 2. From `sdk/python/`:
9
+
10
+ ```bash
11
+ python -m pip install --upgrade build twine
12
+ python -m build
13
+ python -m twine upload dist/*
14
+ ```
15
+
16
+ 3. Tag: `git tag sdk-v<version> && git push origin sdk-v<version>`.
17
+ 4. Smoke test in a clean venv: `pip install eniyan==<version>` and run the
18
+ quickstart against staging.
19
+
20
+ Until the PyPI project exists, early-access installs work from git:
21
+ `pip install "git+https://github.com/Eniyan-Inc/eniyan.git#subdirectory=sdk/python"`.
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "eniyan"
7
+ version = "0.1.0"
8
+ description = "Eniyan SDK — govern your AI agents wherever they run: identity, scoped authority, JIT windows, and self-reported run telemetry."
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Eniyan", email = "stevland@eniyantrust.com" }]
13
+ keywords = ["ai-agents", "agent-identity", "governance", "rbac", "mcp"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Security",
19
+ ]
20
+ dependencies = ["httpx>=0.24"]
21
+
22
+ [project.optional-dependencies]
23
+ mcp = ["mcp>=1.0"]
24
+ dev = ["pytest>=8"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://eniyantrust.com"
28
+ Documentation = "https://eniyantrust.com/docs"
29
+
30
+ [project.scripts]
31
+ eniyan-mcp = "eniyan.mcp.server:main"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/eniyan"]
@@ -0,0 +1,26 @@
1
+ """Eniyan SDK — govern your AI agents wherever they run."""
2
+
3
+ from .client import EniyanClient, GovernanceDecision
4
+ from .errors import (
5
+ EniyanAPIError,
6
+ EniyanAuthError,
7
+ EniyanError,
8
+ EniyanRunError,
9
+ EniyanScopeRefused,
10
+ )
11
+ from .runs import RunHandle, governed_run
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = [
16
+ "EniyanClient",
17
+ "GovernanceDecision",
18
+ "EniyanError",
19
+ "EniyanAPIError",
20
+ "EniyanAuthError",
21
+ "EniyanRunError",
22
+ "EniyanScopeRefused",
23
+ "RunHandle",
24
+ "governed_run",
25
+ "__version__",
26
+ ]
@@ -0,0 +1,268 @@
1
+ """
2
+ EniyanClient — a thin, typed wrapper over the Eniyan HTTP API.
3
+
4
+ Nothing here is invented: every method maps 1:1 onto an existing endpoint.
5
+ The client authenticates with an org API key; agent-scoped calls also carry
6
+ the agent's credential JWT (issued at enrollment).
7
+
8
+ Telemetry sent through this client is METADATA-ONLY by contract: step names,
9
+ scopes, token counts, outcomes. Never send tool arguments, tool results,
10
+ prompts, or model output — the server truncates labels defensively, but the
11
+ contract is yours to honor.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ import httpx
20
+
21
+ from .errors import EniyanAPIError, EniyanAuthError, EniyanRunError, EniyanScopeRefused
22
+
23
+
24
+ @dataclass
25
+ class GovernanceDecision:
26
+ """Outcome of a scope check against the agent's live permitted set."""
27
+
28
+ allowed: bool
29
+ scope: str
30
+ advisory_flagged: bool
31
+ reason: str | None
32
+ raw: dict
33
+
34
+
35
+ class EniyanClient:
36
+ def __init__(
37
+ self,
38
+ base_url: str,
39
+ api_key: str,
40
+ credential_token: str | None = None,
41
+ agent_id: str | None = None,
42
+ timeout: float = 15.0,
43
+ transport: httpx.BaseTransport | None = None,
44
+ ):
45
+ self.base_url = base_url.rstrip("/")
46
+ self.api_key = api_key
47
+ self.credential_token = credential_token
48
+ self.agent_id = agent_id
49
+ self._http = httpx.Client(
50
+ base_url=self.base_url,
51
+ timeout=timeout,
52
+ headers={"Authorization": f"Bearer {api_key}"},
53
+ transport=transport,
54
+ )
55
+
56
+ def close(self) -> None:
57
+ self._http.close()
58
+
59
+ # ── Internals ─────────────────────────────────────────────────────────────
60
+
61
+ def _request(self, method: str, path: str, **kwargs) -> dict:
62
+ resp = self._http.request(method, path, **kwargs)
63
+ if resp.status_code >= 400:
64
+ code, message = None, resp.text[:500]
65
+ try:
66
+ detail = resp.json().get("detail")
67
+ if isinstance(detail, dict):
68
+ code = detail.get("code")
69
+ message = detail.get("message") or message
70
+ elif isinstance(detail, str):
71
+ message = detail
72
+ except Exception:
73
+ pass
74
+ err_cls = EniyanAuthError if resp.status_code in (401, 403) else EniyanAPIError
75
+ raise err_cls(resp.status_code, code, message)
76
+ return resp.json() if resp.content else {}
77
+
78
+ def _require_credential(self) -> str:
79
+ if not self.credential_token:
80
+ raise EniyanAPIError(0, "NO_CREDENTIAL", "This call needs credential_token.")
81
+ return self.credential_token
82
+
83
+ def _require_agent(self, agent_id: str | None = None) -> str:
84
+ resolved = agent_id or self.agent_id
85
+ if not resolved:
86
+ raise EniyanAPIError(0, "NO_AGENT", "Pass agent_id or set it on the client.")
87
+ return resolved
88
+
89
+ # ── Identity & authority ──────────────────────────────────────────────────
90
+
91
+ def verify(self, requested_scope: str | None = None) -> dict:
92
+ """POST /v1/credentials/verify — online verification of this agent's credential."""
93
+ body: dict[str, Any] = {"credential_token": self._require_credential()}
94
+ if requested_scope is not None:
95
+ body["requested_scope"] = requested_scope
96
+ return self._request("POST", "/v1/credentials/verify", json=body)
97
+
98
+ def check_scope(self, scope: str) -> GovernanceDecision:
99
+ """
100
+ Scope decision against the agent's live permitted set (roles, live
101
+ delegations, approved exceptions — whatever the org policy composes).
102
+
103
+ Block mode refusal raises EniyanScopeRefused; advisory mode returns
104
+ allowed=True with advisory_flagged=True (the violation is recorded
105
+ server-side either way).
106
+ """
107
+ result = self.verify(requested_scope=scope)
108
+ authorized = result.get("scope_authorized")
109
+ valid = bool(result.get("valid"))
110
+ if not valid and result.get("reason") == "scope_not_authorized":
111
+ raise EniyanScopeRefused(scope, result.get("reason"))
112
+ if not valid:
113
+ raise EniyanAuthError(200, result.get("reason"), "credential not valid")
114
+ return GovernanceDecision(
115
+ allowed=True,
116
+ scope=scope,
117
+ advisory_flagged=(authorized is False),
118
+ reason=result.get("reason"),
119
+ raw=result,
120
+ )
121
+
122
+ def mint_token(self, scope: str | None = None) -> dict:
123
+ """POST /v1/oauth/token (client_credentials) — short-lived access token."""
124
+ data = {
125
+ "grant_type": "client_credentials",
126
+ "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
127
+ "client_assertion": self._require_credential(),
128
+ }
129
+ if scope:
130
+ data["scope"] = scope
131
+ return self._request("POST", "/v1/oauth/token", data=data)
132
+
133
+ def introspect(self, token: str) -> dict:
134
+ """POST /v1/oauth/introspect (RFC 7662) — live re-evaluation."""
135
+ return self._request("POST", "/v1/oauth/introspect", data={"token": token})
136
+
137
+ def validate_grant(
138
+ self,
139
+ scope: str,
140
+ resource_type: str | None = None,
141
+ resource_id: str | None = None,
142
+ ttl_seconds: int | None = None,
143
+ ) -> dict:
144
+ """POST /v1/agents/credentials/validate — per-action access grant."""
145
+ body: dict[str, Any] = {
146
+ "credential_token": self._require_credential(),
147
+ "scope": scope,
148
+ }
149
+ if resource_type:
150
+ body["resource_type"] = resource_type
151
+ if resource_id:
152
+ body["resource_id"] = resource_id
153
+ if ttl_seconds:
154
+ body["ttl_seconds"] = ttl_seconds
155
+ return self._request("POST", "/v1/agents/credentials/validate", json=body)
156
+
157
+ # ── JIT task windows ──────────────────────────────────────────────────────
158
+
159
+ def create_task(
160
+ self,
161
+ task_label: str,
162
+ agent_id: str | None = None,
163
+ ttl_minutes: int = 15,
164
+ required_scopes: list[str] | None = None,
165
+ ) -> dict:
166
+ agent = self._require_agent(agent_id)
167
+ body: dict[str, Any] = {
168
+ "task_label": task_label,
169
+ "ttl_minutes": ttl_minutes,
170
+ "auto_activate": True,
171
+ }
172
+ if required_scopes is not None:
173
+ body["required_scopes"] = required_scopes
174
+ return self._request("POST", f"/v1/verifications/agents/{agent}/tasks", json=body)
175
+
176
+ def complete_task(
177
+ self, task_id: str, agent_id: str | None = None, reason: str = "completed"
178
+ ) -> dict:
179
+ agent = self._require_agent(agent_id)
180
+ return self._request(
181
+ "POST",
182
+ f"/v1/verifications/agents/{agent}/tasks/{task_id}/complete",
183
+ json={"reason": reason},
184
+ )
185
+
186
+ def attest_task(
187
+ self,
188
+ task_id: str,
189
+ outcome: str,
190
+ notes: str | None = None,
191
+ agent_id: str | None = None,
192
+ ) -> dict:
193
+ agent = self._require_agent(agent_id)
194
+ body: dict[str, Any] = {"outcome": outcome}
195
+ if notes:
196
+ body["notes"] = notes
197
+ return self._request(
198
+ "POST", f"/v1/verifications/agents/{agent}/tasks/{task_id}/attest", json=body
199
+ )
200
+
201
+ # ── Runs (self-reported telemetry) ────────────────────────────────────────
202
+
203
+ def start_run(
204
+ self,
205
+ agent_id: str | None = None,
206
+ harness: str = "custom",
207
+ model: str | None = None,
208
+ agent_task_id: str | None = None,
209
+ ) -> dict:
210
+ body: dict[str, Any] = {
211
+ "agent_id": self._require_agent(agent_id),
212
+ "harness": harness,
213
+ }
214
+ if model:
215
+ body["model"] = model
216
+ if agent_task_id:
217
+ body["agent_task_id"] = agent_task_id
218
+ return self._request("POST", "/v1/runs", json=body)
219
+
220
+ def append_steps(self, run_id: str, steps: list[dict]) -> dict:
221
+ if not steps:
222
+ return {"run_id": run_id, "steps": []}
223
+ try:
224
+ return self._request("POST", f"/v1/runs/{run_id}/steps", json={"steps": steps})
225
+ except EniyanAPIError as err:
226
+ if err.status_code in (404, 409):
227
+ raise EniyanRunError(str(err)) from err
228
+ raise
229
+
230
+ def finish_run(
231
+ self,
232
+ run_id: str,
233
+ status: str = "completed",
234
+ outcome: str | None = None,
235
+ cost_cents: int | None = None,
236
+ ) -> dict:
237
+ body: dict[str, Any] = {"status": status}
238
+ if outcome is not None:
239
+ body["outcome"] = outcome
240
+ if cost_cents is not None:
241
+ body["cost_cents"] = cost_cents
242
+ try:
243
+ return self._request("POST", f"/v1/runs/{run_id}/finish", json=body)
244
+ except EniyanAPIError as err:
245
+ if err.status_code in (404, 409):
246
+ raise EniyanRunError(str(err)) from err
247
+ raise
248
+
249
+ # ── RBAC exception workflow ───────────────────────────────────────────────
250
+
251
+ def request_scope_exception(
252
+ self,
253
+ scope: str,
254
+ justification: str,
255
+ agent_id: str | None = None,
256
+ expires_at: str | None = None,
257
+ ) -> dict:
258
+ """
259
+ POST /v1/agent-roles/agents/{agent_id}/exceptions — ask a human
260
+ approver for out-of-role authority. NOTE: this endpoint requires a
261
+ dashboard JWT (a member requests on the agent's behalf); with an
262
+ API key it returns 401 — surface the request to your operator.
263
+ """
264
+ agent = self._require_agent(agent_id)
265
+ body: dict[str, Any] = {"scope": scope, "justification": justification}
266
+ if expires_at:
267
+ body["expires_at"] = expires_at
268
+ return self._request("POST", f"/v1/agent-roles/agents/{agent}/exceptions", json=body)
@@ -0,0 +1,39 @@
1
+ """Typed exceptions for the Eniyan SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class EniyanError(Exception):
7
+ """Base class for all SDK errors."""
8
+
9
+
10
+ class EniyanAPIError(EniyanError):
11
+ """The API returned an error response."""
12
+
13
+ def __init__(self, status_code: int, code: str | None, message: str):
14
+ self.status_code = status_code
15
+ self.code = code
16
+ self.message = message
17
+ super().__init__(f"[{status_code}] {code or 'error'}: {message}")
18
+
19
+
20
+ class EniyanAuthError(EniyanAPIError):
21
+ """API key or credential rejected (401/403)."""
22
+
23
+
24
+ class EniyanScopeRefused(EniyanError):
25
+ """
26
+ The requested scope was refused under the org's block enforcement mode.
27
+
28
+ `advisory` refusals do NOT raise — the call proceeds and the violation
29
+ is flagged server-side; check GovernanceDecision.advisory_flagged.
30
+ """
31
+
32
+ def __init__(self, scope: str, reason: str | None = None):
33
+ self.scope = scope
34
+ self.reason = reason or "scope_not_authorized"
35
+ super().__init__(f"scope refused: {scope} ({self.reason})")
36
+
37
+
38
+ class EniyanRunError(EniyanError):
39
+ """Run lifecycle failure (finish on a finished run, unknown run, ...)."""
@@ -0,0 +1 @@
1
+ """Eniyan MCP server — governance as MCP tools for any MCP-capable harness."""
@@ -0,0 +1,136 @@
1
+ """
2
+ eniyan-mcp — stdio MCP server exposing Eniyan governance to any harness.
3
+
4
+ Configuration (env):
5
+ ENIYAN_API_URL default https://api.eniyantrust.com
6
+ ENIYAN_API_KEY org API key (required)
7
+ ENIYAN_CREDENTIAL_TOKEN the agent's credential JWT (required for
8
+ scope checks, grants, and OAuth mints)
9
+ ENIYAN_AGENT_ID the agent's public id (required for JIT + runs)
10
+
11
+ Claude Code example (.mcp.json):
12
+ {
13
+ "mcpServers": {
14
+ "eniyan": {
15
+ "command": "eniyan-mcp",
16
+ "env": {
17
+ "ENIYAN_API_URL": "https://api.eniyantrust.com",
18
+ "ENIYAN_API_KEY": "...",
19
+ "ENIYAN_CREDENTIAL_TOKEN": "...",
20
+ "ENIYAN_AGENT_ID": "..."
21
+ }
22
+ }
23
+ }
24
+ }
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+
31
+ from ..client import EniyanClient
32
+ from . import tools
33
+
34
+
35
+ def _client_from_env() -> EniyanClient:
36
+ api_key = os.environ.get("ENIYAN_API_KEY")
37
+ if not api_key:
38
+ raise SystemExit("eniyan-mcp: set ENIYAN_API_KEY")
39
+ return EniyanClient(
40
+ base_url=os.environ.get("ENIYAN_API_URL", "https://api.eniyantrust.com"),
41
+ api_key=api_key,
42
+ credential_token=os.environ.get("ENIYAN_CREDENTIAL_TOKEN"),
43
+ agent_id=os.environ.get("ENIYAN_AGENT_ID"),
44
+ )
45
+
46
+
47
+ def build_server(client: EniyanClient):
48
+ """Bind the governance operations to a FastMCP server instance."""
49
+ try:
50
+ # mcp 2.x renamed FastMCP → MCPServer; support both.
51
+ try:
52
+ from mcp.server.mcpserver import MCPServer as _Server
53
+ except ImportError:
54
+ from mcp.server.fastmcp import FastMCP as _Server
55
+ except ImportError as exc:
56
+ raise SystemExit('eniyan-mcp needs the mcp extra: pip install "eniyan[mcp]"') from exc
57
+
58
+ server = _Server(
59
+ "eniyan",
60
+ instructions=(
61
+ "Eniyan governs this agent's identity and authority. Check scopes "
62
+ "before privileged actions, record metadata-only run telemetry "
63
+ "(names, scopes, token counts — never payloads or prompts), and "
64
+ "route refusals through the human exception workflow."
65
+ ),
66
+ )
67
+
68
+ @server.tool()
69
+ def eniyan_check_scope(scope: str) -> dict:
70
+ """Check whether this agent is currently permitted to use a scope (live decision)."""
71
+ return tools.check_scope(client, scope)
72
+
73
+ @server.tool()
74
+ def eniyan_open_jit_window(
75
+ task_label: str, ttl_minutes: int = 15, required_scopes: list[str] | None = None
76
+ ) -> dict:
77
+ """Open a just-in-time credential window for a task."""
78
+ return tools.open_jit_window(client, task_label, ttl_minutes, required_scopes)
79
+
80
+ @server.tool()
81
+ def eniyan_complete_task(task_id: str, reason: str = "completed") -> dict:
82
+ """Close a JIT window; the credential re-suspends."""
83
+ return tools.complete_task(client, task_id, reason)
84
+
85
+ @server.tool()
86
+ def eniyan_attest(task_id: str, outcome: str, notes: str | None = None) -> dict:
87
+ """File the post-task attestation (as_intended | partial | failed)."""
88
+ return tools.attest(client, task_id, outcome, notes)
89
+
90
+ @server.tool()
91
+ def eniyan_start_run(
92
+ harness: str = "eniyan-mcp", model: str | None = None, agent_task_id: str | None = None
93
+ ) -> dict:
94
+ """Start a self-reported run; record steps against the returned run_id."""
95
+ return tools.start_run(client, harness, model, agent_task_id)
96
+
97
+ @server.tool()
98
+ def eniyan_record_step(
99
+ run_id: str,
100
+ kind: str,
101
+ name: str,
102
+ scope: str | None = None,
103
+ input_tokens: int | None = None,
104
+ output_tokens: int | None = None,
105
+ outcome: str = "ok",
106
+ detail: str | None = None,
107
+ ) -> dict:
108
+ """Record one metadata-only run step. Labels only — never payloads or prompts."""
109
+ return tools.record_step(
110
+ client, run_id, kind, name, scope, input_tokens, output_tokens, outcome, detail
111
+ )
112
+
113
+ @server.tool()
114
+ def eniyan_finish_run(
115
+ run_id: str,
116
+ status: str = "completed",
117
+ outcome: str | None = None,
118
+ cost_cents: int | None = None,
119
+ ) -> dict:
120
+ """Finish the run (completed | failed)."""
121
+ return tools.finish_run(client, run_id, status, outcome, cost_cents)
122
+
123
+ @server.tool()
124
+ def eniyan_request_scope_exception(scope: str, justification: str) -> dict:
125
+ """Ask a human approver for authority outside this agent's roles."""
126
+ return tools.request_scope_exception(client, scope, justification)
127
+
128
+ return server
129
+
130
+
131
+ def main() -> None:
132
+ build_server(_client_from_env()).run()
133
+
134
+
135
+ if __name__ == "__main__":
136
+ main()