ciridae 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 @@
1
+ /_editable/
ciridae-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: ciridae
3
+ Version: 0.1.0
4
+ Summary: Public Python clients, models, and project utilities for Ciridae
5
+ Project-URL: Repository, https://github.com/ciridae-ai/ciridae
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: httpx>=0.28.1
8
+ Requires-Dist: pydantic>=2.11.7
9
+ Requires-Dist: pyyaml>=6.0.3
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Ciridae
13
+
14
+ Public Python clients, models, and project utilities consumed by Ciridae
15
+ services, standalone projects, and templates.
16
+
17
+ The domain libraries remain the source owners in `libs/auth-client`,
18
+ `libs/mcp-access`, and `libs/project-contract`. This directory only assembles
19
+ their `ciridae.*` namespaces into one public distribution.
20
+
21
+ `public-package-files.txt` is the reviewed artifact boundary. Adding any source
22
+ file under an owned namespace fails the build until that file is explicitly
23
+ added to the manifest.
24
+
25
+ The PyPI distribution is `ciridae`. Import from the relevant domain namespace:
26
+
27
+ ```python
28
+ from ciridae.auth import AuthServiceAccessResponse, AuthServiceClient
29
+ from ciridae.mcp import CiridaeMcpClient, ProjectToolInventoryResponse
30
+ from ciridae.project import ProjectDefinition
31
+ ```
32
+
33
+ Validate a `project.yaml` file with the same packaged contract:
34
+
35
+ ```console
36
+ ciridae validate-project project.yaml
37
+ ```
38
+
39
+ ## Releasing
40
+
41
+ 1. Update `version` in `pyproject.toml`.
42
+ 2. Merge the change to `main`.
43
+ 3. Push the matching tag, for example `ciridae-v0.1.0`.
44
+
45
+ The tag publishes through PyPI Trusted Publishing. Releases are immutable;
46
+ change the version rather than replacing an existing artifact.
@@ -0,0 +1,35 @@
1
+ # Ciridae
2
+
3
+ Public Python clients, models, and project utilities consumed by Ciridae
4
+ services, standalone projects, and templates.
5
+
6
+ The domain libraries remain the source owners in `libs/auth-client`,
7
+ `libs/mcp-access`, and `libs/project-contract`. This directory only assembles
8
+ their `ciridae.*` namespaces into one public distribution.
9
+
10
+ `public-package-files.txt` is the reviewed artifact boundary. Adding any source
11
+ file under an owned namespace fails the build until that file is explicitly
12
+ added to the manifest.
13
+
14
+ The PyPI distribution is `ciridae`. Import from the relevant domain namespace:
15
+
16
+ ```python
17
+ from ciridae.auth import AuthServiceAccessResponse, AuthServiceClient
18
+ from ciridae.mcp import CiridaeMcpClient, ProjectToolInventoryResponse
19
+ from ciridae.project import ProjectDefinition
20
+ ```
21
+
22
+ Validate a `project.yaml` file with the same packaged contract:
23
+
24
+ ```console
25
+ ciridae validate-project project.yaml
26
+ ```
27
+
28
+ ## Releasing
29
+
30
+ 1. Update `version` in `pyproject.toml`.
31
+ 2. Merge the change to `main`.
32
+ 3. Push the matching tag, for example `ciridae-v0.1.0`.
33
+
34
+ The tag publishes through PyPI Trusted Publishing. Releases are immutable;
35
+ change the version rather than replacing an existing artifact.
@@ -0,0 +1,23 @@
1
+ """Client and public models for the Ciridae Auth Service."""
2
+
3
+ from ciridae.auth.access import (
4
+ AuthServiceAccessResponse,
5
+ FirebaseUid,
6
+ FirebaseUidValue,
7
+ )
8
+ from ciridae.auth.api_error import (
9
+ AuthServiceApiError,
10
+ AuthServiceRejectedError,
11
+ AuthServiceUnavailableError,
12
+ )
13
+ from ciridae.auth.client import AuthServiceClient
14
+
15
+ __all__ = [
16
+ "AuthServiceAccessResponse",
17
+ "AuthServiceApiError",
18
+ "AuthServiceClient",
19
+ "AuthServiceRejectedError",
20
+ "AuthServiceUnavailableError",
21
+ "FirebaseUid",
22
+ "FirebaseUidValue",
23
+ ]
@@ -0,0 +1,23 @@
1
+ """Auth Service access-check response consumed by project backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, ClassVar, NewType
6
+
7
+ from pydantic import BaseModel, ConfigDict, StringConstraints
8
+
9
+ FirebaseUid = NewType("FirebaseUid", str)
10
+ FirebaseUidValue = Annotated[
11
+ FirebaseUid,
12
+ StringConstraints(min_length=1, strip_whitespace=True),
13
+ ]
14
+
15
+
16
+ class AuthServiceAccessResponse(BaseModel):
17
+ """Result of verifying one bearer against a project's Auth realm."""
18
+
19
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", frozen=True)
20
+
21
+ has_access: bool
22
+ user_id: FirebaseUidValue | None
23
+ email: str | None
@@ -0,0 +1,22 @@
1
+ """Auth Service client error."""
2
+
3
+
4
+ class AuthServiceApiError(RuntimeError):
5
+ """Raised when an Auth Service request does not complete successfully."""
6
+
7
+ def __init__(self, *, status_code: int | None = None) -> None:
8
+ """Record whether the Auth Service returned an HTTP status.
9
+
10
+ Args:
11
+ status_code: The response status, or ``None`` for a transport failure.
12
+ """
13
+ super().__init__("Auth Service request failed")
14
+ self.status_code: int | None = status_code
15
+
16
+
17
+ class AuthServiceRejectedError(AuthServiceApiError):
18
+ """Raised when the Auth Service definitively rejects a request."""
19
+
20
+
21
+ class AuthServiceUnavailableError(AuthServiceApiError):
22
+ """Raised when no trustworthy Auth Service response is available."""
@@ -0,0 +1,76 @@
1
+ """HTTP client for the Ciridae Auth Service."""
2
+
3
+ from typing import Literal
4
+
5
+ import httpx
6
+
7
+ from ciridae.auth.api_error import (
8
+ AuthServiceRejectedError,
9
+ AuthServiceUnavailableError,
10
+ )
11
+
12
+
13
+ class AuthServiceClient(httpx.AsyncClient):
14
+ """Async client bound to one Auth Service endpoint and service API key."""
15
+
16
+ def __init__(
17
+ self,
18
+ *,
19
+ api_url: str,
20
+ api_key: str,
21
+ timeout: float | httpx.Timeout,
22
+ transport: httpx.AsyncBaseTransport | None = None,
23
+ ) -> None:
24
+ """Configure the Auth Service URL, bearer, and transport policy.
25
+
26
+ Args:
27
+ api_url: Auth Service API root.
28
+ api_key: Project-scoped service API key.
29
+ timeout: HTTPX timeout policy for Auth Service requests.
30
+ transport: Optional transport override for boundary tests.
31
+ """
32
+ super().__init__(
33
+ base_url=api_url,
34
+ headers={"Authorization": f"Bearer {api_key}"},
35
+ timeout=timeout,
36
+ follow_redirects=False,
37
+ trust_env=False,
38
+ transport=transport,
39
+ )
40
+
41
+ async def request_success(
42
+ self,
43
+ method: Literal["GET", "POST", "DELETE"],
44
+ path: str,
45
+ *,
46
+ params: dict[str, str | int] | None = None,
47
+ json: dict[str, object] | None = None,
48
+ ) -> httpx.Response:
49
+ """Make one request and return only a definite 2xx response.
50
+
51
+ Args:
52
+ method: HTTP method.
53
+ path: Path relative to the configured Auth Service API root.
54
+ params: Optional query parameters.
55
+ json: Optional JSON request body.
56
+
57
+ Returns:
58
+ The successful Auth Service response.
59
+
60
+ Raises:
61
+ AuthServiceApiError: If transport fails or the service returns a
62
+ non-2xx status.
63
+ """
64
+ try:
65
+ response = await self.request(method, path, params=params, json=json)
66
+ except httpx.RequestError:
67
+ # Leave the exception boundary before raising: RequestError retains
68
+ # its request, whose headers carry the bearer.
69
+ response = None
70
+ if response is None:
71
+ raise AuthServiceUnavailableError
72
+ if response.is_success:
73
+ return response
74
+ if response.is_server_error:
75
+ raise AuthServiceUnavailableError(status_code=response.status_code)
76
+ raise AuthServiceRejectedError(status_code=response.status_code)
@@ -0,0 +1,45 @@
1
+ """Client and public models for Ciridae MCP project access."""
2
+
3
+ from ciridae.mcp.access import (
4
+ DEFAULT_MCP_ACCESS_MODE,
5
+ ConnectCode,
6
+ ConnectUserId,
7
+ McpAccessCategoryMode,
8
+ McpAccessMode,
9
+ )
10
+ from ciridae.mcp.api_error import CiridaeMcpApiError
11
+ from ciridae.mcp.client import (
12
+ CiridaeMcpClient,
13
+ fetch_project_tool_inventory,
14
+ mint_connect_link,
15
+ )
16
+ from ciridae.mcp.connect_link import ConnectLink
17
+ from ciridae.mcp.project_tool_inventory import (
18
+ AvailableProjectToolInventoryResponse,
19
+ ProjectToolInventoryItemResponse,
20
+ ProjectToolInventoryResponse,
21
+ UnavailableProjectToolInventoryResponse,
22
+ )
23
+ from ciridae.mcp.tool_operation import HttpMethod, ToolOperation
24
+ from ciridae.project import ProjectId, ProjectIdValue
25
+
26
+ __all__ = [
27
+ "DEFAULT_MCP_ACCESS_MODE",
28
+ "AvailableProjectToolInventoryResponse",
29
+ "CiridaeMcpApiError",
30
+ "CiridaeMcpClient",
31
+ "ConnectCode",
32
+ "ConnectLink",
33
+ "ConnectUserId",
34
+ "HttpMethod",
35
+ "McpAccessCategoryMode",
36
+ "McpAccessMode",
37
+ "ProjectId",
38
+ "ProjectIdValue",
39
+ "ProjectToolInventoryItemResponse",
40
+ "ProjectToolInventoryResponse",
41
+ "ToolOperation",
42
+ "UnavailableProjectToolInventoryResponse",
43
+ "fetch_project_tool_inventory",
44
+ "mint_connect_link",
45
+ ]
@@ -0,0 +1,26 @@
1
+ """Access vocabulary shared with the Ciridae MCP gateway."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Literal
6
+
7
+ from pydantic import Field
8
+
9
+ ConnectCode = Annotated[
10
+ str,
11
+ Field(
12
+ min_length=1,
13
+ description="Single-use code pasted at the gateway's consent screen.",
14
+ ),
15
+ ]
16
+ ConnectUserId = Annotated[
17
+ str,
18
+ Field(
19
+ min_length=1,
20
+ max_length=128,
21
+ description="User ID the minted connect link will act as.",
22
+ ),
23
+ ]
24
+ McpAccessMode = Literal["read_only", "all", "selected"]
25
+ McpAccessCategoryMode = Literal["read_only", "all"]
26
+ DEFAULT_MCP_ACCESS_MODE: McpAccessMode = "read_only"
@@ -0,0 +1,25 @@
1
+ """Custom exception type raised by Ciridae MCP gateway calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CiridaeMcpApiError(RuntimeError):
7
+ """Raised when a Ciridae MCP gateway call cannot complete successfully.
8
+
9
+ Covers a gateway that never answered as well as one that answered wrongly,
10
+ because every caller handles the two the same way.
11
+
12
+ Attributes:
13
+ status_code: The status the gateway returned, or `None` when the
14
+ failure happened before a response arrived.
15
+ """
16
+
17
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
18
+ """Build a `CiridaeMcpApiError`.
19
+
20
+ Args:
21
+ message: Human-readable description of the failure.
22
+ status_code: The status the gateway returned, if it answered.
23
+ """
24
+ super().__init__(message)
25
+ self.status_code: int | None = status_code
@@ -0,0 +1,211 @@
1
+ """HTTP client for the Ciridae MCP gateway."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from http import HTTPStatus
7
+
8
+ import httpx
9
+ from ciridae.mcp.access import ConnectUserId, McpAccessMode
10
+ from ciridae.mcp.api_error import CiridaeMcpApiError
11
+ from ciridae.mcp.connect_link import ConnectLink
12
+ from ciridae.mcp.endpoint import CiridaeMcpApiRoot
13
+ from ciridae.mcp.project_tool_inventory import ProjectToolInventoryResponse
14
+ from ciridae.mcp.tool_operation import ToolOperation
15
+ from ciridae.project import ProjectId
16
+ from pydantic import SecretStr, TypeAdapter, ValidationError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Bound both local gateway work and downstream project-catalog reads.
21
+ _DEFAULT_TIMEOUT_SECONDS = 30.0
22
+ _PROJECT_TOOL_INVENTORY_ADAPTER: TypeAdapter[ProjectToolInventoryResponse] = (
23
+ TypeAdapter(ProjectToolInventoryResponse)
24
+ )
25
+
26
+
27
+ class CiridaeMcpClient(httpx.AsyncClient):
28
+ """Async client bound to one Ciridae MCP gateway and management key."""
29
+
30
+ def __init__(
31
+ self,
32
+ *,
33
+ api_root: CiridaeMcpApiRoot,
34
+ management_key: SecretStr,
35
+ transport: httpx.AsyncBaseTransport | None = None,
36
+ ) -> None:
37
+ """Configure the fixed gateway URL, bearer, and transport policy.
38
+
39
+ Args:
40
+ api_root: The gateway API root; `to_api_root` derives this from a
41
+ validated `CIRIDAE_MCP_ENDPOINT`.
42
+ management_key: The project endpoint-scoped management key.
43
+ transport: Optional transport override for boundary tests.
44
+ """
45
+ super().__init__(
46
+ base_url=api_root,
47
+ timeout=_DEFAULT_TIMEOUT_SECONDS,
48
+ headers={"Authorization": f"Bearer {management_key.get_secret_value()}"},
49
+ # Both are about where the bearer can end up. httpx already
50
+ # defaults to not following redirects; stated here because a
51
+ # redirect the gateway did not author would carry the header to
52
+ # whatever host it named. `trust_env=False` is the one that
53
+ # changes behaviour: it stops HTTPS_PROXY from putting a third
54
+ # party on the path of every mint.
55
+ follow_redirects=False,
56
+ trust_env=False,
57
+ transport=transport,
58
+ )
59
+
60
+
61
+ async def fetch_project_tool_inventory(
62
+ client: CiridaeMcpClient,
63
+ *,
64
+ expected_project_id: ProjectId,
65
+ ) -> ProjectToolInventoryResponse:
66
+ """Fetch the compiled inventory authorized by a project's management key.
67
+
68
+ Args:
69
+ client: The client bound to this deployment's gateway and bearer.
70
+ expected_project_id: The canonical project the caller expects the key
71
+ to select.
72
+
73
+ Returns:
74
+ The gateway's available or explicitly unavailable inventory.
75
+
76
+ Raises:
77
+ CiridaeMcpApiError: If the gateway cannot be reached, rejects the
78
+ request, answers unreadably, or selects a different project.
79
+ """
80
+ try:
81
+ response = await client.get("/v1/tools")
82
+ except httpx.RequestError:
83
+ # `from None`: `RequestError.request.headers` carries the bearer.
84
+ msg = "Ciridae MCP could not be reached"
85
+ raise CiridaeMcpApiError(msg) from None
86
+ if response.status_code != HTTPStatus.OK:
87
+ # No body: a rejection can echo the request.
88
+ logger.warning(
89
+ "Ciridae MCP tool inventory failed with status %s", response.status_code
90
+ )
91
+ msg = f"Ciridae MCP refused the tool inventory (status={response.status_code})"
92
+ raise CiridaeMcpApiError(msg, status_code=response.status_code)
93
+ try:
94
+ # The gateway owns this response and may add metadata before every
95
+ # consumer deploys. Keep the producer model closed while making this
96
+ # cross-service read boundary forward-compatible with additive fields.
97
+ inventory = _PROJECT_TOOL_INVENTORY_ADAPTER.validate_json(
98
+ response.content,
99
+ extra="ignore",
100
+ )
101
+ except ValidationError:
102
+ # The validation error may quote the upstream body.
103
+ msg = "Ciridae MCP returned a tool inventory that could not be read"
104
+ raise CiridaeMcpApiError(msg) from None
105
+ if inventory.project_id != expected_project_id:
106
+ msg = "Ciridae MCP returned a tool inventory for a different project"
107
+ raise CiridaeMcpApiError(msg)
108
+ return inventory
109
+
110
+
111
+ async def mint_connect_link(
112
+ client: CiridaeMcpClient,
113
+ *,
114
+ user_id: ConnectUserId,
115
+ access: McpAccessMode,
116
+ tools: tuple[ToolOperation, ...] | None = None,
117
+ ) -> ConnectLink:
118
+ """Mint a connect link the named user can redeem in an MCP client.
119
+
120
+ The management key is the whole authorization; the gateway does not
121
+ re-check membership, so passing an authenticated UID is a caller
122
+ obligation.
123
+
124
+ Args:
125
+ client: The client bound to this deployment's gateway and bearer.
126
+ user_id: User id of the member the link will act as.
127
+ access: The access the redeemed connection is granted.
128
+ tools: The exact operations granted when ``access`` is ``selected``;
129
+ the gateway refuses any other pairing.
130
+
131
+ Returns:
132
+ The minted link. Its URL is visible only in this response.
133
+
134
+ Raises:
135
+ CiridaeMcpApiError: If the gateway cannot be reached, rejects the
136
+ request, or answers with an unreadable link or one bound to a
137
+ different user or access mode.
138
+ """
139
+ payload: dict[str, object] = {"user_id": user_id, "access": access}
140
+ if tools is not None:
141
+ payload["tools"] = [
142
+ {
143
+ "method": operation.method,
144
+ "path_template": operation.path_template,
145
+ }
146
+ for operation in tools
147
+ ]
148
+ try:
149
+ response = await client.post("/v1/connect-links", json=payload)
150
+ except httpx.RequestError:
151
+ # `from None`: `RequestError.request.headers` carries the bearer.
152
+ msg = "Ciridae MCP could not be reached"
153
+ raise CiridaeMcpApiError(msg) from None
154
+ _raise_for_status_or_error(response)
155
+ link = _parse_link(response.content)
156
+ if link is None:
157
+ msg = "Ciridae MCP returned a connect link that could not be read"
158
+ raise CiridaeMcpApiError(msg)
159
+ if (
160
+ link.user_id != user_id
161
+ or link.access != access
162
+ or _selection_set(link.tools) != _selection_set(tools)
163
+ ):
164
+ msg = "Ciridae MCP minted a connect link for a different grant"
165
+ raise CiridaeMcpApiError(msg)
166
+ return link
167
+
168
+
169
+ def _selection_set(
170
+ tools: tuple[ToolOperation, ...] | None,
171
+ ) -> frozenset[ToolOperation] | None:
172
+ """Compare selections by membership; order on the wire is not semantic.
173
+
174
+ Returns:
175
+ The selection as a set, or ``None`` when no selection was made.
176
+ """
177
+ return None if tools is None else frozenset(tools)
178
+
179
+
180
+ def _raise_for_status_or_error(response: httpx.Response) -> None:
181
+ """Return for a 2xx mint response, otherwise raise.
182
+
183
+ Args:
184
+ response: The gateway's mint response.
185
+
186
+ Raises:
187
+ CiridaeMcpApiError: For any non-2xx status.
188
+ """
189
+ if HTTPStatus.OK <= response.status_code < HTTPStatus.MULTIPLE_CHOICES:
190
+ return
191
+ # No body: a rejection can echo the request or carry a signed URL.
192
+ logger.warning("Ciridae MCP mint failed with status %s", response.status_code)
193
+ msg = f"Ciridae MCP rejected mint (status={response.status_code})"
194
+ raise CiridaeMcpApiError(msg, status_code=response.status_code)
195
+
196
+
197
+ def _parse_link(content: bytes) -> ConnectLink | None:
198
+ """Parse a `ConnectLink` from a gateway response body.
199
+
200
+ Args:
201
+ content: The raw response body.
202
+
203
+ Returns:
204
+ The parsed link, or `None` when the body cannot be read. Returning
205
+ rather than raising keeps `ValidationError` (which quotes its input)
206
+ out of both `__cause__` and `__context__`.
207
+ """
208
+ try:
209
+ return ConnectLink.model_validate_json(content)
210
+ except ValidationError:
211
+ return None
@@ -0,0 +1,36 @@
1
+ """Wire schema for a connect link returned by the Ciridae MCP gateway."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar, Self
6
+
7
+ from ciridae.mcp.access import ConnectCode, ConnectUserId, McpAccessMode
8
+ from ciridae.mcp.tool_operation import ToolOperation
9
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, model_validator
10
+
11
+
12
+ class ConnectLink(BaseModel):
13
+ """A single-use, expiring credential that connects one user's MCP client."""
14
+
15
+ model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True)
16
+
17
+ code: ConnectCode
18
+ user_id: ConnectUserId
19
+ access: McpAccessMode
20
+ tools: tuple[ToolOperation, ...] | None = None
21
+ expires_at: AwareDatetime
22
+
23
+ @model_validator(mode="after")
24
+ def _selection_matches_the_access(self) -> Self:
25
+ """Require an exact operation list only for selected access.
26
+
27
+ Returns:
28
+ The validated connect link.
29
+
30
+ Raises:
31
+ ValueError: The access mode and tool selection disagree.
32
+ """
33
+ if (self.access == "selected") is not (self.tools is not None):
34
+ msg = f"access={self.access!r} does not match tools={self.tools!r}"
35
+ raise ValueError(msg)
36
+ return self